Skip to content

perf: eliminate AotConverterRegistry from generated code with typed casts#5529

Open
thomhurst wants to merge 27 commits intomainfrom
perf/inline-aot-converters
Open

perf: eliminate AotConverterRegistry from generated code with typed casts#5529
thomhurst wants to merge 27 commits intomainfrom
perf/inline-aot-converters

Conversation

@thomhurst
Copy link
Copy Markdown
Owner

@thomhurst thomhurst commented Apr 13, 2026

Summary

  • Generates typed casts in invoke lambdas and instance factories where source types are known at compile time, letting the C# compiler resolve conversion operators statically
  • Handles mixed data sources (e.g. [Arguments] + [ClassDataSource<T>]) by generating pattern-matching switch expressions with per-type arms, so a single shared lambda can handle multiple runtime source types
  • Uses ITypedDataSourceAttribute<T> marker interface to discover source types — works with built-in and user-defined custom data sources, no hardcoded attribute names
  • Falls back to CastHelper.Cast<T>() for dynamic data sources ([MethodDataSource], non-generic [ClassDataSource], etc.) where source types can't be determined at compile time
  • Deletes AotConverterHelper.cs (338 lines) — eliminates compile-time type scanning for conversion operators and runtime ConcurrentDictionary lookups in AotConverterRegistry
  • Preserves public APIAotConverterRegistry, IAotConverter, FuncAotConverter, and CastHelper remain for users who register converters manually

Breaking change (AOT + dynamic data sources + custom operators)

If you combine [MethodDataSource], non-generic [ClassDataSource(typeof(T))], or other non-statically-typed data sources with custom implicit/explicit conversion operators in Native AOT mode, you must now manually register converters:

AotConverterRegistry.RegisterConverter<TSource, TTarget>(...);

Previously, the source generator automatically scanned for and registered these converters at compile time. This auto-registration has been removed because the typed cast approach makes it unnecessary for the vast majority of cases.

Recommended fix: Migrate to [ClassDataSource<T>] (generic form) wherever possible — this gives the source generator full type information and generates correct typed casts without any runtime registry.

This does not affect:

  • [Arguments(...)] — types are always known from the typed constants
  • [ClassDataSource<T>] (generic form) — type is known from the generic parameter
  • Any data source implementing ITypedDataSourceAttribute<T> — type is known from the interface
  • Non-AOT (JIT) mode — CastHelper.Cast<T>() uses reflection which works fine at runtime

New/rewritten files

  • CastExpressionHelper.cs — uses Compilation.ClassifyConversion to decide between typed cast, multi-source switch, and CastHelper fallback
  • SourceTypeAnalyzer.cs — extracts compile-time source types from all data source attributes per parameter position; supports [Arguments] (via TypedConstant), ITypedDataSourceAttribute<T> (via generic type args), and unknown sources (fallback)

Key changes

  • TupleArgumentHelper.cs — method invocation args use CastExpressionHelper.GenerateCastForPosition() instead of always emitting CastHelper.Cast<T>()
  • InstanceFactoryGenerator.cs — constructor parameter casts use typed casts when source types are known
  • TestMetadataGenerator.cs — wires source type analysis into all invoke code generation paths; removes AOT converter infrastructure
  • ClassTestGroup.cs — removes AotConverterRegistrationCode property

Generated code examples

// Before (all cases):
instance.MyTest(global::TUnit.Core.Helpers.CastHelper.Cast<int>(args[0]));

// After — single source type known from [Arguments(42)]:
instance.MyTest((int)args[0]);

// After — implicit conversion, e.g. int → Temperature:
instance.MyTest((global::MyNamespace.Temperature)(int)args[0]);

// After — multiple source types (e.g. [Arguments(TestEnum.One)] + [Arguments(-1)]):
instance.MyTest((args[0] switch {
    global::TestEnum __s => (global::TestEnum)__s,
    int __s => (global::TestEnum)__s,
    _ => (global::TestEnum)args[0]
}));

// After — dynamic data source (unchanged):
instance.MyTest(global::TUnit.Core.Helpers.CastHelper.Cast<Temperature>(args[0]));

Test plan

  • Snapshot tests pass on all TFMs (net472, net8.0, net9.0, net10.0) — including all multi-target variants
  • Full solution builds with 0 errors
  • Integration tests pass: ArgumentWithImplicitConverterTests, BasicTests, DataDrivenTests, NumberArgumentTests, MatrixTests, StringArgumentTests, ClassConstructorTest
  • Mixed [Arguments] + [ClassDataSource<T>] scenario generates correct switch expressions (fixes DataSourceCountTests.ArgumentsWithClassDataSourceTests AOT failure)
  • [MethodDataSource] and other dynamic sources correctly fall back to CastHelper.Cast<T>()
  • Code simplification pass (reuse, quality, efficiency review)

Copy link
Copy Markdown
Contributor

@claude claude Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This is a well-motivated refactor that addresses real problems: the old was a full-compilation scan (O(all syntax trees)) that invalidated on any keystroke, and the generated classes were verbose boilerplate. The architectural direction — inlining registrations into the per-class source and using string-based equality for incremental caching — is the right call.


Correctness concern: duplicate AOT registrations per generated file

Both and emit calls. For a typical non-generic class, collects all method symbols for the class and emits one class-level registration block. Then, is called for each test method individually and emits a second per-method registration block into the same file.

This means a type pair like will appear in both the class-level partial block and one or more per-method partial blocks in the same file. Runtime behavior depends on whether is idempotent (i.e., safe to call with the same type pair multiple times). If it isn't (e.g., it throws or double-registers), this is a silent correctness bug.

Suggestion: Either emit only at the class level (which already has all method symbols via ), or skip the per-method call in for non-generic, non-inherited cases. The two emission sites currently run independently with separate HashSets, so deduplication only works within one call, not across the pair.


Weak incremental caching tests

The new tests don't actually verify what they claim. only asserts — that's trivially true regardless of whether the pipeline cached or re-ran.

Compare to the deleted , which used:

That's the only assertion that proves caching actually happened. The new tests verify output exists, not that the pipeline didn't re-execute. They'd pass even if the string-based equality fix was reverted.

Suggestion: Use assertions (as in the deleted tests) to verify the pipeline step was cached. The helper presumably enables tracking — use it to assert for the metadata step when an unrelated type is added.


uses only

The new implementation only compares :

If the class's inherited test methods change (e.g., a attribute is added to a base class method), the incremental pipeline will not invalidate the cached metadata for this class because its fully-qualified name didn't change. This could silently produce stale generated output until a clean rebuild.

Suggestion: Consider whether changes (e.g., compilation timestamp or a hash) should also factor into equality, or document explicitly that this class relies on Roslyn's upstream invalidation to handle method-body/attribute changes.


Minor: not marked on

The new property on is nullable/optional, which is fine. But since is a , the equality and hash code are auto-generated and will include this field — so correctness is preserved. Just worth noting that this field's absence from is intentional (nullable opt-in), not an oversight.


Positive highlights

  • Eliminating the fan-in was the right call. The old AOT pipeline invalidated all converters when any test changed — this was the core perf anti-pattern.
  • **String-based equality in ** is a well-documented, principled fix for the ISymbol identity problem. The implementation correctly handles array-typed (the trap on arrays).
  • ** reuse** removes ~200 lines of duplicated type-hierarchy walking. Good cleanup.
  • Snapshot diff (1073 → 643 lines for ) shows the generated code reduction is real and significant.

The two issues above (duplicate registrations and weak incremental tests) are worth addressing before merge, but the overall architecture is a clear improvement.

@codacy-production
Copy link
Copy Markdown

codacy-production Bot commented Apr 13, 2026

Up to standards ✅

🟢 Issues 0 issues

Results:
0 new issues

View in Codacy

🟢 Metrics 119 complexity

Metric Results
Complexity 119

View in Codacy

NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.

Copy link
Copy Markdown
Contributor

@claude claude Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This is a well-motivated refactor that addresses real problems: the old AotConverterGenerator was a full-compilation scan (O(all syntax trees)) that invalidated on any keystroke, and the generated IAotConverter classes were verbose boilerplate. The architectural direction - inlining registrations into the per-class source and using string-based equality for incremental caching - is the right call.


Correctness concern: duplicate AOT registrations per generated file

Both GeneratePerClassTestSource and GenerateTestMetadata emit EmitAotConverterRegistration calls. For a typical non-generic class, GroupMethodsByClass collects all method symbols for the class and emits one class-level registration block. Then, GenerateTestMetadata is called for each test method individually and emits a second per-method registration block into the same .g.cs file.

This means a type pair will appear in both the class-level partial block and one or more per-method partial blocks in the same file. Runtime behavior depends on whether AotConverterRegistry.Register<S,T> is idempotent (safe to call with the same type pair multiple times). If it is not (e.g., it throws or double-registers), this is a silent correctness bug.

Suggestion: Either emit only at the class level (which already has all method symbols via GroupMethodsByClass), or skip the per-method GenerateRegistrationCode call in GenerateTestMetadata for non-generic, non-inherited cases. The two emission sites currently run independently with separate seen HashSets, so deduplication only works within one call, not across the pair.


Weak incremental caching tests

The new TestMetadataGeneratorIncrementalTests tests do not actually verify what they claim. AddUnrelatedType_ShouldNotRegenerateTestMetadata only asserts result2.GeneratedTrees.Length > 0 - that is trivially true regardless of whether the pipeline cached or re-ran.

Compare to the deleted AotConverterGeneratorIncrementalTests, which used:

AssertRunReasons(driver2, IncrementalGeneratorRunReasons.Cached, 1);

That is the only assertion that proves caching actually happened. The new tests verify output exists, not that the pipeline did not re-execute. They would pass even if the string-based equality fix was reverted.

Suggestion: Use IncrementalGeneratorRunReasons assertions (as in the deleted tests) to verify the pipeline step was cached. The TestHelper.GenerateTracked helper presumably enables tracking - use it to assert Cached for the metadata step when an unrelated type is added.


InheritsTestsClassMetadata.Equals uses only TypeFullyQualifiedName

The new IEquatable implementation only compares TypeFullyQualifiedName. If the class's inherited test methods change (e.g., a [Skip] attribute is added to a base class method), the incremental pipeline will not invalidate the cached metadata for this class because its fully-qualified name did not change. This could silently produce stale generated output until a clean rebuild.

Suggestion: Consider whether CompilationContext changes (e.g., a SyntaxTree hash) should also factor into equality, or document explicitly that this class relies on Roslyn's upstream invalidation to handle method-body/attribute changes.


Minor: AotConverterRegistrationCode not marked required on ClassTestGroup

The new AotConverterRegistrationCode property is nullable/optional. Since ClassTestGroup is a record, equality and hash code are auto-generated and will include this field, so correctness is preserved. Just worth noting that the absence of required is intentional (nullable opt-in).


Positive highlights

  • Eliminating the .Collect() fan-in was the right call. The old AOT pipeline invalidated all converters when any test changed - that was the core perf anti-pattern.
  • String-based equality in TestMethodMetadata is a well-documented, principled fix for the ISymbol identity problem. The ComputeAttributeHash implementation correctly handles array-typed TypedConstant.Values (the .Value trap on arrays).
  • DataSourceAttributeHelper reuse removes ~200 lines of duplicated type-hierarchy walking. Good cleanup.
  • Snapshot diff (1073 to 643 lines for DecimalArgumentTests) shows the generated code reduction is real and significant.

The two issues above (duplicate registrations and weak incremental tests) are worth addressing before merge, but the overall architecture is a clear improvement.

Copy link
Copy Markdown
Contributor

@claude claude Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Follow-up Review (addressing commit 0e7c8f5)

The second commit directly addresses the weak incremental caching tests feedback from the prior review — thank you. The changes replace the meaningless GeneratedTrees.Length > 0 assertions with proper TrackedSteps-based Cached/Modified assertions, matching the pattern from DynamicTestsGeneratorIncrementalTests. The ParseTestMetadata constant and WithTrackingName wiring are exactly right. That concern is resolved.


Still open: duplicate AOT registrations

The core correctness concern from the prior review was not addressed. The two emission paths remain independent:

  1. GroupMethodsByClass (in GeneratePerClassTestSource) scans all methods and emits one class-level registration block with its own seen HashSet.
  2. GenerateTestMetadata is then called for each test method and emits a second per-method registration block with a fresh seen HashSet.

For a class with any test methods, both paths run, and the same AotConverterRegistry.Register<S,T> call will appear in multiple partial blocks in the same .g.cs file. The snapshot diffs confirm this — e.g., DecimalArgumentTests.Test.verified.txt shows a single class-level registration block with all 22 converter calls. But in a real compilation with multiple methods, the per-method path would also fire and produce overlapping blocks.

Whether this is a live bug depends entirely on whether AotConverterRegistry.Register<S,T> is idempotent. If it silently no-ops on duplicates, it's a latent inefficiency. If it throws or double-adds, it's a runtime failure. This should be either:

  • Eliminated: emit registrations only from GeneratePerClassTestSource (which already has all method symbols) and remove the EmitAotConverterRegistration call from GenerateTestMetadata, or
  • Documented: add a comment explaining why duplicates are safe (with a reference to AotConverterRegistry idempotency guarantee).

Still open: InheritsTestsClassMetadata.Equals only compares TypeFullyQualifiedName

The IEquatable implementation added in commit 1 uses only the class name for equality. This means the incremental pipeline will not invalidate cached metadata for a class if a base class method gains or loses an attribute (e.g., [Skip], [Arguments]). The class name hasn't changed, so Roslyn considers the node unchanged and skips regeneration.

This may be acceptable if upstream ForAttributeWithMetadataName invalidation cascades correctly through the inheritsTestsClassesProvider pipeline for attribute changes on base methods. But if that's the reasoning, it should be documented — otherwise it looks like an incomplete equality implementation.


Summary

Issue Status
Weak incremental caching tests Resolved in commit 2
Duplicate AOT registrations (correctness risk) Still open
InheritsTestsClassMetadata over-caching Still open

The second commit is a clean fix. The two remaining issues from the first review are still worth addressing before merge.

Copy link
Copy Markdown
Contributor

@claude claude Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review: perf: inline AOT converter registrations into per-class test source

The core idea is sound and the incremental caching improvement is real. Replacing ISymbol equality with string-based equality and moving away from the CompilationProvider-scoped AOT generator is the right direction. Here are my findings:


Issue 1 (Potential Bug): IsAccessibleType silently skips type-argument accessibility for public generic types

File: TUnit.Core.SourceGenerator/Generators/AotConverterHelper.cs

For a type like Wrapper<InternalType> where Wrapper is public but InternalType is internal to another assembly, the method returns true at the DeclaredAccessibility == Public check before it reaches the IsGenericType block that validates type arguments. The type-argument check is only reached if the code doesn't return early — but public types return early.

This means a converter for Wrapper<InternalType> would be emitted, producing a compile error in the generated file rather than a graceful skip. This bug was present in AotConverterGenerator before, but since the logic is being explicitly carried forward here, it's worth fixing:

if (namedType.DeclaredAccessibility == Accessibility.Public)
{
    if (namedType.IsGenericType)
    {
        foreach (var typeArg in namedType.TypeArguments)
        {
            if (!IsAccessibleType(typeArg, compilation))
                return false;
        }
    }
    return true;
}

Issue 2 (Potential Bug): Non-generic inherited tests may miss AOT registrations

File: TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs

The per-method AOT registration block guards with if (testMethod.IsGenericType || testMethod.IsGenericMethod). The comment says non-generic methods are covered by GroupMethodsByClass, but that pipeline only processes methods that are not generic and not inherited (via InheritanceDepth > 0 filtering). Non-generic inherited tests travel through GenerateInheritedTestSourcesGenerateTestMetadata but hit the IsGenericType || IsGenericMethod guard and are skipped.

Repro: Base class with [Test, Arguments(5)] void Test(MyType t) where MyType has an implicit conversion from int, plus a subclass with [InheritsTests]. The inherited test won't get its AOT converter registered.

Suggestion:

if (testMethod.IsGenericType || testMethod.IsGenericMethod || testMethod.InheritanceDepth > 0)

Issue 3 (Null Safety): FuncAotConverter lacks null guard for value-type conversions

File: TUnit.Core/Converters/FuncAotConverter.cs

public object? Convert(object? value) => converter((TSource)value!);

When value is null and TSource is a value type, the null-suppression ! followed by the cast throws NullReferenceException. The old class-based converters had an explicit if (value == null) return null; guard. Since this PR now exclusively generates lambda-based converters (removing the old class-based approach), this path is completely unprotected.

// Option A: fix FuncAotConverter
public object? Convert(object? value) => value is null ? null : converter((TSource)value);

Issue 4 (Documentation): Per-class AOT path still in .Collect() — PR claims are slightly overstated

File: TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs (lines 77–82)

The PR description says it "removes the .Collect() fan-in for the AOT converter branch." This is only partially true. The per-class AOT path still runs AotConverterHelper.GenerateRegistrationCode inside .Collect(), meaning it re-runs for all classes on any test method edit. The actual performance win is from the per-method path (generics), which is now correctly scoped per-method.

This is fine — the ClassTestGroup record's structural equality means Roslyn won't re-emit unchanged class files — but the claim in the PR description overstates the win. Worth a comment in code clarifying the actual caching boundary.


Issue 5 (Dead Code): Redundant condition in ScanTypedConstantForTypes

File: TUnit.Core.SourceGenerator/Generators/AotConverterHelper.cs (around line 212)

else if (constant.Kind != TypedConstantKind.Array && constant is { Value: not null, Type: not null })

This is inside an else branch, so constant.Kind == TypedConstantKind.Array is already false. The Kind != TypedConstantKind.Array check is dead code. Simplify to:

else if (constant is { Value: not null, Type: not null })

What's Good

  1. Core incremental caching fix is correct: Using string-based equality instead of ISymbol identity in TestMethodMetadata.Equals is exactly the right Roslyn incremental generator pattern. Symbols lose identity across compilations; strings don't.

  2. MethodAttributeHash approach is sound: Hashing TypedConstant values avoids cross-compilation identity issues while still detecting actual attribute changes. The ArrayArguments_ShouldNotThrow test directly covers the known TypedConstant.Value pitfall for array kinds.

  3. Generated output is dramatically simpler: ~20 lines per converter reduced to 1 lambda per converter. static source => (T)source is AOT-safe, allocates nothing, and registers in FuncAotConverter<S,T> — a clean design.

  4. Correct reuse of DataSourceAttributeHelper.IsDataSourceAttribute: The old AotConverterGenerator duplicated this logic with a slightly different implementation. Removing the duplication is the right call.

  5. New incremental tests are well-targeted: TestMetadataGeneratorIncrementalTests covers the three most important scenarios (unrelated type add, attribute change, array argument edge case). These tests would have caught the original over-invalidation bug.


Issues 2 and 3 are the most important to address before merging — they represent behavioral regressions for non-generic inherited tests and null-value conversions respectively. Issue 1 would produce a compile error (visible and fixable) rather than silent incorrectness. Issues 4 and 5 are minor.

Copy link
Copy Markdown
Contributor

@claude claude Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This is a well-motivated refactor with solid execution. The core ideas — inlining AOT converter registrations per-class, eliminating the fan-in .Collect(), and switching TestMethodMetadata equality to string-based identity — are all correctness and performance improvements. The diff is substantial but the changes are coherent and the snapshot test coverage provides good safety nets.

A few observations and one correctness concern follow.


Bug: IsAccessibleType incorrectly returns true for public nested types inside inaccessible containers

In AotConverterHelper.IsAccessibleType (lines 240-253), when namedType.DeclaredAccessibility == Accessibility.Public, the method returns true without checking whether the type is nested inside an inaccessible containing type:

if (namedType.DeclaredAccessibility == Accessibility.Public)
{
    if (namedType.IsGenericType) { ... check type args ... }
    return true;  // ← BUG: ignores ContainingType
}

A public nested type inside a private or protected outer class is not actually accessible from outside the outer class. The old AotConverterGenerator.cs had the same gap (it also returned true for Accessibility.Public without checking the container), so this is not a regression introduced by this PR — but the refactor is a good opportunity to fix it.

Suggested fix: After the generic-type-arg checks, add if (namedType.ContainingType != null) return IsAccessibleType(namedType.ContainingType, compilation); before the final return true, analogous to what the fallthrough path already does for other accessibility levels.


Design: seen deduplication is per-call, not per-file

AotConverterHelper.GenerateRegistrationCode creates a fresh seen HashSet on each call. For the per-class path this is fine — one call per class, one generated block. For the generic/inherited per-method path (called from GenerateTestMetadata) a fresh seen set is created per method invocation. Duplicate registrations across methods in the same file will be emitted, which is harmless at runtime because AotConverterRegistry.TryAdd silently deduplicates, but it does produce slightly noisier generated output (and marginally larger binaries in AOT builds). The comment in Initialize() acknowledges this explicitly, so this is a deliberate trade-off — worth noting for future cleanup.


Design: ComputeAttributeHash is order-sensitive but attribute order is not guaranteed

ComputeAttributeHash uses a position-dependent hash (hash = (hash * 31) ^ ...). Two methods with the same set of attributes in different orders will produce different hashes, causing unnecessary cache invalidation. Since Roslyn does not guarantee a stable ordering of GetAttributes() results across incremental compilations (though in practice it is stable), this could cause spurious cache misses. Using an order-independent combination (XOR-ing all per-attribute hashes together, or sorting by attribute name first) would be more robust:

// More cache-stable alternative
hash = attributes
    .Select(attr => HashAttribute(attr))
    .Aggregate(0, (acc, h) => acc ^ h);

This is a minor concern because attribute order rarely changes, but it is worth considering since the entire purpose of MethodAttributeHash is incremental-cache stability.


Design: InheritsTestsClassMetadata equality only compares TypeFullyQualifiedName

The added Equals/GetHashCode for InheritsTestsClassMetadata uses only TypeFullyQualifiedName. The comment explains the intentional trade-off:

A clean rebuild handles the rare case where a base class method's attributes change without touching the subclass.

This is an acceptable and clearly documented decision. However, it means that any change to a base class's test methods (added [Category], changed [Timeout], etc.) will require a clean rebuild to take effect — IDE hot-reload will silently serve stale generated code. That behaviour should probably be surfaced in the doc comment as a known limitation rather than buried as an implementation note.


Minor: AotConverterHelper is public but appears internal-use-only

AotConverterHelper and its overloads are public static, but the class lives in the source-generator assembly and is only called from TestMetadataGenerator. Making it internal would be cleaner and consistent with DataSourceAttributeHelper which is internal static. This prevents accidental external dependencies and makes the intended scope clear.


Positive highlights

  • Eliminating the CompilationProvider.Select fan-in for AOT converters is the right call. The old design would re-scan the entire compilation whenever any test changed — O(n) work on every keystroke. The new per-class approach correctly ties the scan to the classes that actually need it.
  • FuncAotConverter null guard fix (value is null ? null : converter((TSource)value)) is a correctness improvement that was missing before and protects against NPEs on nullable test parameters.
  • String-based equality for TestMethodMetadata is the correct pattern for Roslyn incremental generators. Using ISymbol in equality comparisons is a known anti-pattern that defeats incremental caching.
  • Reusing DataSourceAttributeHelper.IsDataSourceAttribute instead of the hand-rolled type-hierarchy walk in the old generator is a good reduction in duplicated logic.
  • New TestMetadataGeneratorIncrementalTests directly tests the caching behaviour; the ArrayArguments_ShouldNotThrow test case in particular guards the subtle TypedConstant.Value throw-on-array edge case.

Summary

The refactor is well-executed and the key goals (performance, correctness of incremental caching, reduced generated code size) are all achieved. The one item worth fixing before merge is the IsAccessibleType nested-type correctness gap. The hash ordering and internal visibility concerns are low-priority but worth tracking.

Copy link
Copy Markdown
Contributor

@claude claude Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Follow-up Review (commit e3e4aa1)

The latest commit directly addresses the two remaining concerns from the last review:

Resolved: IsAccessibleType nested-type fix

The fix is correct and well-placed. For public types, the method now:

  1. Validates generic type arguments (prevents emitting converters for Wrapper<InternalType>)
  2. Recursively checks the containing type (prevents emitting converters for public Inner inside private Outer)

The ordering is right — type-arg check comes before container check, so both conditions must pass, and returning false from the type-arg loop short-circuits correctly.

One minor observation: the Internal branch does not recurse into ContainingType — it only does an assembly GivesAccessTo check and returns. A nested internal type inside an inaccessible container would be incorrectly allowed through. However, this is a pre-existing edge case not introduced by this PR and not worth blocking on here.

Resolved: AotConverterHelper is now internal

Making it internal static is the right scope. It's called only from TestMetadataGenerator within the same assembly, and the internal visibility makes that contract explicit.


Remaining (pre-existing) minor concern: ComputeAttributeHash order-sensitivity

This was flagged as minor in the previous review and is not addressed here (nor should it block this PR). The XOR-based alternative would be more cache-stable, but the practical risk is negligible since Roslyn's GetAttributes() is stable within a compilation and the test suite covers the detection correctness.


Summary

All issues raised across the review cycle have been addressed:

Issue Status
Weak incremental caching tests Resolved (commit 2)
Duplicate AOT registrations Resolved (commit 3)
InheritsTestsClassMetadata over-caching Documented with clear comment (commit 3)
IsAccessibleType generic type-arg gap Resolved (commit 4)
Non-generic inherited tests missing AOT registration Resolved (commit 4)
FuncAotConverter null guard for value types Resolved (commit 4)
Dead code in ScanTypedConstantForTypes Resolved (commit 4)
IsAccessibleType public nested type gap Resolved (commit 5)
AotConverterHelper visibility Resolved (commit 5)
ComputeAttributeHash order-sensitivity Minor / pre-existing, does not block

The PR is in good shape. The core goals — eliminating the CompilationProvider-scoped AOT generator, fixing ISymbol-based equality, and dramatically simplifying the generated output — are all achieved and well-tested. Good to merge.

@thomhurst thomhurst changed the title perf: inline AOT converter registrations into per-class test source perf: eliminate AotConverterRegistry from generated code with typed casts Apr 13, 2026
Copy link
Copy Markdown
Contributor

@claude claude Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review: perf/inline-aot-converters

This is a substantial and well-executed performance improvement. Eliminating the runtime AotConverterRegistry lookup in favour of compile-time typed casts is exactly the right direction, especially for AOT compatibility. The net -361 lines (1063 deleted, 702 added) while preserving the public API surface is impressive.


Architecture Concerns

1. Constructor selection mismatch between SourceTypeAnalyzer and GetPrimaryConstructor

SourceTypeAnalyzer.GetConstructorParameterSourceTypes picks the constructor with the most parameters:

if (member is IMethodSymbol { MethodKind: MethodKind.Constructor, IsStatic: false, IsImplicitlyDeclared: false } ctor
    && (constructor == null || ctor.Parameters.Length > constructor.Parameters.Length))
{
    constructor = ctor;
}

But InstanceFactoryGenerator.GetPrimaryConstructor checks for [TestConstructor] first, then falls back to the most-public/most-parameters constructor. If a class has a [TestConstructor]-annotated 1-param constructor alongside a 2-param one, GetConstructorParameterSourceTypes analyses types based on the 2-param constructor's parameterCount, while the factory generates a call to the 1-param constructor. In most cases this is harmless (position 0 is still correct), but it's an internal inconsistency that could produce unexpected results for multi-constructor classes with [TestConstructor].

Suggested fix: Extract GetPrimaryConstructor to a shared location and use it in both places, so source-type analysis always targets the same constructor the factory will call.


2. Params-array optimization is incomplete and produces inconsistent generated code

The snapshot for ArgsAsArrayTests reveals this asymmetry:

// case 2: only the first element gets a typed cast
new string[] { (string)args[0], global::TUnit.Core.Helpers.CastHelper.Cast<string>(args[1]) }

// case 3: still only the first
new string[] { (string)args[0], global::TUnit.Core.Helpers.CastHelper.Cast<string>(args[1]), global::TUnit.Core.Helpers.CastHelper.Cast<string>(args[2]) }

The root cause: GetMethodParameterSourceTypes sizes sourceTypes to parameterCount (1 for a params string[] method). So GetSourceTypeAt(sourceTypes, i) for i >= 1 always returns null, falling back to CastHelper.Cast. But all elements come from the same [Arguments("a", "b", "c")] attribute and have statically-known types.

The conceptual mismatch is: sourceTypes is indexed by parameter position, but params-array elements are indexed by argument position. One parameter can consume N arguments.

A possible fix would be to return a flat argument-position array from ExtractSourceTypesFromArguments (sized to max argument count across all [Arguments] instances) rather than a parameter-count-sized array — or have a separate path for params parameters that iterates across all argument positions.

This is an optimization gap, not a correctness bug, but it creates a visually confusing pattern in generated code that maintainers might find misleading.


3. Stale test class name

AotConverterGeneratorTests.GeneratesCode() is [Skip]ped and now delegates to TestMetadataGenerator.RunTest since the generator it tested was deleted. The class name is now misleading. Either rename the class to reflect what it's actually testing, or remove it entirely since the functionality moved into TestMetadataGeneratorIncrementalTests.


What Works Well

  • CastExpressionHelper.GenerateCast correctly handles the three cases (same type, compiler-resolvable conversion, unknown fallback) using Compilation.ClassifyConversion — letting the C# compiler's own analysis drive the decision rather than reimplementing conversion logic.
  • SourceTypeAnalyzer.ExtractSourceTypesFromArguments correctly handles mixed-type [Arguments] rows (marks positions as null when types are inconsistent across rows), null literals, and error constants.
  • Backward-compatible public API preservation: AotConverterRegistry, IAotConverter, FuncAotConverter, and CastHelper remain for user-registered converters. The migration path is additive.
  • Comprehensive test coverage: 468 snapshot tests across 4 TFMs, plus targeted integration tests for the conversion scenarios.
  • Incremental generator hygiene: The new TestMetadataGeneratorIncrementalTests properly validates that unchanged method metadata doesn't trigger re-output — important for editor performance.

Summary

The core design is sound and the performance benefit is real. The two architectural issues (constructor selection inconsistency and params-array gap) are worth addressing before merge — especially the constructor one, since it's a latent correctness risk for the [TestConstructor] scenario. The params issue can be tracked as a follow-up if it's out of scope.

Copy link
Copy Markdown
Contributor

@claude claude Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Follow-up Review (commit 1b092b7)

The single commit since the previous review updates platform-specific snapshot files for net8.0, net9.0, and net472 — six files in total, all .verified.txt. This is purely a tooling artifact from accepting snapshots generated on those TFMs and contains no logic changes.


Previous Review — Status

Concern Status
Non-transitive comparator in SortMostDerivedFirst ✅ Resolved (bd9e4d0)
Default arm used bare cast instead of CastHelper ✅ Resolved (bd9e4d0)
IsBoxing shortcut constrained to object/ValueType/Enum targets ✅ Resolved (968bbb7)
FullProjectGenerationTests rename ✅ Resolved (6e4cd0b)
All other concerns from prior rounds ✅ Resolved
Matrix attribute name-only checks (minor) ⚠️ Still outstanding

Remaining Minor: Matrix attribute name-only checks

This was flagged in the previous review as non-blocking. For completeness: IsMatrixDataSourceAttribute and the per-parameter attribute name checks in ProcessMatrixDataSource still use name-only comparisons:

// SourceTypeAnalyzer.cs
private static bool IsMatrixDataSourceAttribute(AttributeData attr)
    => attr.AttributeClass?.Name is "MatrixDataSourceAttribute";  // no namespace check

foreach (var paramAttr in param.GetAttributes())
{
    if (paramAttr.AttributeClass?.Name is not ("MatrixAttribute" or "MatrixMethodAttribute" or "MatrixRangeAttribute"))
        continue;

Compare with IsArgumentsAttribute, which validates both name and fully-qualified namespace:

private static bool IsArgumentsAttribute(AttributeData attr)
    => attr.AttributeClass?.Name is "ArgumentsAttribute"
    && attr.AttributeClass.ToDisplayString() == WellKnownFullyQualifiedClassNames.ArgumentsAttribute.WithoutGlobalPrefix;

The practical risk is minimal — a user would have to define a custom attribute named exactly MatrixDataSourceAttribute for this to matter. That said, adding entries to WellKnownFullyQualifiedClassNames for the Matrix types and adding the ToDisplayString() guard would make this consistent and future-proof. Not blocking.


Snapshot Files

The six updated snapshot files (ConflictingNamespaceTests.MatrixTest_WithConflictingNamespace and Tests1889.Test for net4.7/net8/net9) are the expected diff from the matrix source type analysis added in 32999dc. They are consistent with the generated switch-cast pattern documented in the PR description and correct.


Overall

This PR is in excellent shape. All substantive concerns from the multiple review rounds have been resolved. The architecture — replacing runtime AotConverterRegistry lookups with compiler-verified typed casts generated at source-gen time — is the right long-term direction for both AOT compatibility and incremental build performance. The ITypedDataSourceAttribute<T> interface extension point means user-defined data sources automatically benefit without any action on their part.

The matrix attribute namespace check is the only remaining inconsistency, and it is minor enough that it could be addressed as a follow-up. Ready to merge.

Copy link
Copy Markdown
Contributor

@claude claude Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Follow-up Review (commit 1b092b7)

The single commit since the previous review updates platform-specific snapshot files for net8.0, net9.0, and net472 — six files total. This is purely a tooling artifact from accepting snapshots generated on those TFMs and contains no logic changes.

Previous Review — Status

All substantive concerns from the multiple review rounds remain resolved. The only previously noted outstanding item:

Matrix attribute name-only checks (minor, non-blocking)

IsMatrixDataSourceAttribute and the per-parameter attribute name checks in ProcessMatrixDataSource use name-only comparisons, unlike IsArgumentsAttribute which also validates the fully-qualified namespace via ToDisplayString(). The practical risk is minimal (a user would need to define a custom attribute named exactly MatrixDataSourceAttribute), but adding entries to WellKnownFullyQualifiedClassNames and matching the IsArgumentsAttribute pattern would make this consistent. Not blocking.

Snapshot Files

The six updated snapshot files (ConflictingNamespaceTests.MatrixTest_WithConflictingNamespace and Tests1889.Test for net4.7/net8/net9) are the expected diff from the matrix source type analysis added in 32999dc. They are consistent with the generated switch-cast pattern and correct.

Overall

This PR is in excellent shape. All substantive concerns have been addressed across multiple rounds of review. The architecture — replacing runtime AotConverterRegistry lookups with compiler-verified typed casts at source-gen time — is the right long-term direction for AOT compatibility and incremental build performance. Ready to merge.

…iles

Eliminates the separate AotConverters.g.cs file and its pipeline branch
(which used .Collect() — a fan-in that invalidated all converters when
any test method changed). Converter registrations are now emitted inline
in the same generated file as the test entries, using simple lambda-based
AotConverterRegistry.Register<S,T>() calls.

Key changes:
- Merge AotConverterGenerator into TestMetadataGenerator as inline code
- Replace IAotConverter class generation with one-line lambda registrations
- Remove .Collect() fan-in for converters (was a perf anti-pattern)
- Add string-based equality to TestMethodMetadata for incremental caching
- Use existing DataSourceAttributeHelper instead of hand-rolled checks
Replace weak GeneratedTrees.Length assertions with proper
TrackedSteps-based assertions that verify the pipeline step
was actually cached/modified, matching the pattern used by
DynamicTestsGeneratorIncrementalTests.
… caching

- Skip AOT converter emission in per-method path for non-generic methods,
  since they are already covered by the per-class GroupMethodsByClass path.
  This eliminates the overlap for inherited non-generic test methods.
- Document InheritsTestsClassMetadata equality design: upstream
  ForAttributeWithMetadataName caching prevents re-evaluation for base
  class attribute changes, so TypeFullyQualifiedName-only equality is safe.
- Fix IsAccessibleType to check type arguments of public generic types
  before returning true (e.g. Wrapper<InternalType> was incorrectly
  treated as accessible)
- Include inherited tests (InheritanceDepth > 0) in per-method AOT
  registration path, since subclass constructors may need different
  converters than the base class
- Add null guard in FuncAotConverter.Convert to handle null values
  for value-type conversions instead of throwing NullReferenceException
- Remove dead TypedConstantKind.Array check in ScanTypedConstantForTypes
  (already excluded by prior else-if branch)
- IsAccessibleType now checks ContainingType for public nested types,
  preventing emission of converters for types like public Inner inside
  private Outer that are not actually accessible
- Make AotConverterHelper internal (only used by TestMetadataGenerator)
…rRegistry from generated code

Instead of scanning types for conversion operators and emitting AotConverterRegistry.Register<TSource, TTarget>()
calls at compile time (which CastHelper.Cast<T> looks up at runtime), the source generator now analyzes [Arguments]
attribute TypedConstants to determine source types and emits typed double-casts like (TargetType)(SourceType)args[i]
that the C# compiler resolves directly. Dynamic data sources fall back to CastHelper.Cast<T>().
- Reuse InstanceFactoryGenerator.GetPrimaryConstructor in SourceTypeAnalyzer
  so source type analysis targets the same constructor (respecting [TestConstructor])
- Rename AotConverterGeneratorTests → FullProjectGenerationTests (stale name)
… handling

- SourceTypeAnalyzer now sizes the source type array to max argument count
  (not parameter count) for params methods, so elements beyond position 0
  get typed casts instead of CastHelper.Cast fallback
- TupleArgumentHelper.GetParamsElementType now extracts element types from
  generic collection params (IEnumerable<T>, List<T>, etc.), not just T[],
  fixing a pre-existing bug where params IEnumerable<T> would try to cast
  a single element to the collection type instead of constructing an array
Numeric conversions are always classified as implicit or explicit,
so the IsNumeric guard was unreachable.
Any conversion where IsImplicit or IsExplicit is true already implies
Exists is true, making the Exists check redundant.

Re: source type caching suggestion — the three GetMethodParameterSourceTypes
call sites are on mutually exclusive code paths (per-class switch case,
per-entry inline lambda, generic fallback), so a given method is only
analyzed once per generation run. Caching in TestMethodMetadata would
require storing ITypeSymbol which breaks incremental equality.
When source type is unknown for params elements beyond [Arguments] row
length, default to the element type. Since SourceTypeAnalyzer already
verified all data sources are [Arguments], these overflow positions use
the same element type as target, producing (T)args[i] instead of
CastHelper.Cast<T>(args[i]).
…entsAttribute check

- When source type boxes to target (e.g., int → object), skip the cast
  entirely since args[i] is already object
- Add namespace check to IsArgumentsAttribute to avoid matching
  user-defined attributes with the same name
Boxing-to-interface (e.g., int → IComparable) also sets IsBoxing=true,
but returning bare args[i] would fail to compile since object? cannot
be implicitly converted to an interface type. Restrict the shortcut to
System.Object, System.ValueType, and System.Enum where args[i] is
already the correct type.
- Explain why mixed data sources cause whole-method fallback
- Skip already-inconsistent positions early in ExtractSourceTypesFromArguments
- Remove firstRow variable (redundant with null check after early-continue)
- Fix stale "use Math.Min" comment in dynamic count path
Tooling artifact from accepting received files on Windows — the test
runner writes UTF-8 with BOM but the existing snapshots are plain ASCII.
- Remove leftover firstRow variable from ExtractSourceTypesFromArguments
- Mix TypedConstant.Type into ComputeAttributeHash to distinguish
  int 1 from string "1" for incremental cache invalidation
- Use WellKnownFullyQualifiedClassNames for ArgumentsAttribute check
  instead of raw string literals
- Use AsSpan().Fill(true) instead of manual loop for consistent[] init
- Collapse 4 identical cast-loops into 2 using Math.Min for upper bound
  (argCount is int.MaxValue for dynamic count, making the branch redundant)
…taSource

When a class has both [Arguments] and [ClassDataSource<T>] attributes,
the generated InstanceFactory lambda is shared across all data source
rows. A single static cast can't handle both argument types (e.g. int
from Arguments and IntDataSource from ClassDataSource), causing
InvalidCastException in AOT mode where CastHelper can't discover
implicit conversions via reflection.

Rewrite SourceTypeAnalyzer to collect ALL possible source types per
parameter position across all data source attributes, using the
ITypedDataSourceAttribute<T> marker interface (not hardcoded attribute
names) so user custom data sources are supported. When multiple source
types exist at a position, CastExpressionHelper generates a pattern-
matching switch expression with per-type arms, compiling all necessary
conversions at build time.

Fixes DataSourceCountTests.ArgumentsWithClassDataSourceTests AOT failure.
…cast

The DotNet8_0, DotNet9_0, and Net4_7 variants of the
ConflictingNamespaceTests snapshot were not updated with the new
switch expression pattern for enum casts, only DotNet10_0 was.
- Delete dead GetSourceTypeAt method
- Collapse SourceTypeInfo to single GetTypes() method, removing
  HasSingleType/HasMultipleTypes/GetSingleType (callers use count check)
- Change SourceTypeInfo from public to internal sealed
- Change TupleArgumentHelper from public to internal
- Eliminate GenerateElementCast dispatch duplication by adding
  fallbackSourceType parameter to GenerateCastForPosition
- Remove wasteful .Where(IsArgumentsAttribute).ToList() allocation;
  GetMaxArgumentCount now filters inline
- Simplify GenerateMultiSourceCast branch structure
Non-generic [ClassDataSource(typeof(T))] doesn't expose the source type
to SourceTypeAnalyzer, causing fallback to CastHelper.Cast<T>() which
can't discover implicit conversion operators in AOT mode (the deleted
AotConverterGenerator previously auto-registered these).

Convert all 7 affected test files to generic [ClassDataSource<T>] so the
source type is statically known and the generator can emit typed casts.

Also delete orphaned AotConverterGeneratorTests.GeneratesCode.verified.txt
(its test class was renamed to FullProjectGenerationTests).
…ry comment

When source and target types are identical in GenerateMultiSourceCast,
emit `Type __s => __s` instead of `Type __s => (Type)__s` — the pattern
match already binds the typed value, so the cast is redundant.

Add comment explaining why GenerateMultiSourceCast uses single casts
while GenerateCast uses double casts (pattern match performs the unbox).
…by inheritance

DataSourceAttributeHelper used backtick notation (`1) for open generic
matching, but InterfaceCache.GetGenericInterface compares against
GloballyQualified() output which uses <> notation. This prevented the
source generator from recognizing typed data source attributes, causing
fallback to CastHelper.Cast<T>() which fails under AOT.

Also sort multi-source switch arms most-derived-first to prevent CS8510
unreachable pattern errors when source types have inheritance relationships.
Address two review findings:
- Default arm in multi-source switch now uses CastHelper.Cast<T>() instead
  of a bare cast, preventing NullReferenceException when args[i] is null
  for value-type targets.
- Add deterministic string tiebreaker in SortMostDerivedFirst comparator
  to satisfy Introsort's total-order requirement for unrelated types.
…safe casts

MatrixDataSourceAttribute was treated as an unknown data source in
SourceTypeAnalyzer, causing all parameters to fall back to CastHelper.Cast.
In AOT mode, CastHelper can't discover implicit operators via reflection,
breaking tests like ImplicitConversion(TestEnum2 -> OneOf<TestEnum, TestEnum2>).

Now ProcessMatrixDataSource examines per-parameter [Matrix] attributes to
extract compile-time value types, enabling typed switch arms in generated code.
Auto-generated parameters without [Matrix] use the parameter type directly,
except for enums (Enum.GetValuesAsUnderlyingType boxes as int, not enum type).
Copy link
Copy Markdown
Contributor

@claude claude Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review — Follow-up Pass

This is a second look after the previous review. Good news: all three issues raised previously have been addressed. Summary below.


Previous issues — resolved ✅

1. Duplicate AOT registrations
Fully resolved. The entire AotConverterRegistry.Register(new AotConverter_N()) pattern is gone. Generated code no longer emits registration calls at all — typed casts are inlined directly at call sites. The registry only exists now for user-provided FuncAotConverter scenarios.

2. Weak incremental caching tests
Fixed properly. The new TestMetadataGeneratorIncrementalTests uses AssertRunReasons(driver2, IncrementalGeneratorRunReasons.Cached) — the correct assertion that verifies the pipeline step actually cached, not just that output exists. The ChangeTestAttribute_ShouldRegenerate test verifying the positive case (re-run on change) is a nice addition.

3. FuncAotConverter null safety
Fixed: value is null ? null : converter((TSource)value). No null-reference risk for value-type TSource conversions.


InheritsTestsClassMetadata equality — acceptable design decision

The equality using only TypeFullyQualifiedName now has a detailed inline comment explaining the tradeoff and the reliance on Roslyn's upstream invalidation for base class attribute changes. The comment is accurate and sufficient — this is an acceptable design decision given how ForAttributeWithMetadataName works.


New observations

Pattern variable naming in multi-source switch expressions

The generated switch arms use __s as the pattern variable:

(args[0] switch {
    global::TestEnum __s => (global::TestEnum)__s,
    int __s => (global::TestEnum)__s,
    _ => (global::TestEnum)args[0]
})

Each arm's __s is correctly scoped and can't conflict between arms. The only theoretical risk is shadowing an outer-scope variable named __s, but since this is emitted into a generated lambda body that contains nothing else, there's no conflict in practice. Acceptable as-is, though a prefixed name like __tunit_s would be zero-risk.

Boxing special case in GenerateCast is correctly handled

The comment in CastExpressionHelper:

"Do NOT skip the cast for boxing-to-interface (e.g. int → IComparable) — args[i] is object?, which cannot be implicitly converted to an interface type."

This is a subtle correctness invariant. The implementation correctly gates the no-cast path on SpecialType.System_Object | System_ValueType | System_Enum only. Good catch.

SortMostDerivedFirst handles unrelated types gracefully

The sort falls back to string.Compare for types that are neither base/derived of each other. This is a correct total-order tiebreaker — Introsort requires it, and string.Compare on fully-qualified names is deterministic across compilations. No issue here.

SourceTypeInfo per-position design

Using a IReadOnlyList<ITypeSymbol>?[] indexed by parameter position is clean — null means "unknown/fallback", single element means "typed cast", multiple elements means "switch expression". The (uint)index < (uint)_typesPerPosition.Length bounds check avoids a branch for the common case. Well-designed.


Summary

All three previously raised concerns are resolved. The architectural direction — eliminating compile-time type scanning and inlining typed casts — is correct and well-executed. The snapshot diff confirms the generated code reduction is real (1376 lines of AOT converter boilerplate removed, per-method invocation code is cleaner).

This looks ready to merge.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant