Skip to content

fix(generic): build generic class instances from class data, never from method rows - #7192

Merged
thomhurst merged 3 commits into
fix/issue-7118-generic-row-pairingfrom
fix/issue-7161-generic-ctor-from-class-data
Oct 20, 2026
Merged

thomhurst merged 3 commits into
fix/issue-7118-generic-row-pairingfrom
fix/issue-7161-generic-ctor-from-class-data

Conversation

@thomhurst

@thomhurst thomhurst commented Oct 20, 2026 •

Copy link
Copy Markdown
Owner

Fixes #7161

Stacked on #7149 (#7118), which this builds on and extends. The base is fix/issue-7118-generic-row-pairing, so this diff shows only the #7161 change. GitHub retargets it to main when #7149 merges.

Summary

On a generic class, a method-level [Arguments] row can infer the class's type arguments when no class-level [Arguments] row infers them. That happens when the class types come from a class-level [MethodDataSource] or from [GenerateGenericTest]. In those cases, both modes now build the instance from the class-level data. Neither mode passes the method row's values to the constructor any more.

Shape (from the issue) Source-gen before Reflection before Both modes after
1. Class [MethodDataSource] yielding 1; method rows (1), ("a") 2 pass: C<string> built from "a", class data ignored Test(a) runs on C<int> and fails: String cannot be converted to Int32 Each row runs on the instantiation it infers, built from the data source: C<int>(1) and C<string>("1")
2. [Arguments(1)] + [GenerateGenericTest(typeof(string))]; method rows (1), ("a") D<string>.Test(a) passes, built from "a" D<string> built from class row 1 → "1" D<string> built from class row 1 → "1", the [GenerateGenericTest] semantics, in both modes

Cause

  • Source generator (EmitConcreteInstanceCreation): it baked SpecificArgumentsAttribute's values into the constructor even when that attribute was a method row. EmitConcreteDataSources also gave such an instantiation no class data sources, so the class-level [MethodDataSource] / [Arguments] rows never reached it.
  • Reflection (ReflectionTestDataCollector): fix(engine): run method rows only on the generic class instantiation they infer #7149 scopes method rows to the instantiation they infer only when the class has class-level [Arguments] rows. With any other class data source, every method row ran on every instantiation the data source yielded.

Fix

Known limit: a class-level data source can yield rows of several types, such as an untyped object[] source that yields both 1 and "x". For a method row ("a"), reflection then gives C<string> only the string row, while the generator gives it every row. The generator cannot split such a source at compile time.

Tests

  • New tests/TUnit.TestProject/Bugs/_7161/ClassDataForMethodRowInstantiationTests.cs covers both shapes from the issue. Each asserts that the instance is built from the class data ("5"). Before the fix it failed in both modes: 3 of 4 failed in source-gen and 1 of 4 in reflection. After the fix, 4 of 4 pass in both modes.
  • New Issue7161Tests in TUnit.Engine.Tests, plus snapshot test Tests7161.
  • Snapshot Tests7118.Test.verified.txt changed in one place. GenerateGenericTestOfUninferredType<object> now uses a typed constructor call with CastHelper.Cast<object> instead of Activator.CreateInstance(..., args). Behaviour is the same.
  • All on net10.0 unless noted:
    • TUnit.Core.SourceGenerator.Tests: passed on net10.0, net8.0 and net472.
    • TUnit.Engine.Tests, Reflection leg: Issue7161Tests, Issue7118Tests, Issue7106Tests, Issue7107Tests, Issue7096Tests, Issue7105Tests, Issue7121Tests, Issue7123Tests, Issue7092Tests, GenericConstraintValidationTests and GenericMethodWithDataSourceTests all passed. The AOT leg is skipped locally.
    • Source-gen mode: ran TUnit.TestProject directly, with and without --reflection, and compared totals and failures. These are identical in both modes:
      • Bugs._7107, _7096, _7118, _7161, _7092, _7105, _7121 and _7123;
      • GenericTests, GenericMethodTests, GenericTypeTests, TestMixedGenericParameters, SmartInferenceTests, ClassAndMethodArgumentsTests, GenericInstanceMethodDataSourceTests and GenericConstraintValidationTests.
      • Bugs._7106 gives 57 tests in source-gen and 65 in reflection, the same as on fix(engine): run method rows only on the generic class instantiation they infer #7149. The extra tests are reflection-only classes.

Review follow-ups (9149a7b)

  • Reflection now treats the generic [Arguments<T>] form as a class-level [Arguments] row, as the generator does.
  • The generator now infers class type arguments from a class-level [Arguments<T>(value)] row. Before, it inferred none, and every instantiation of such a class failed. New test: Bugs/_7161/GenericArgumentsClassRowWithUnmatchedMethodRow.
  • For an instantiation that only method rows infer, reflection reuses the class rows that discovery already read, instead of reading the class data sources again.
  • Re-ran: TUnit.Core.SourceGenerator.Tests on net10.0, net9.0, net8.0 and net472. Also the Bugs._7096/_7105/_7106/_7107/_7118/_7121/_7123/_7161/_7092 and generic suites in both modes, with identical results, and Issue7161Tests/Issue7118Tests.

…om method rows

A method-level [Arguments] row that infers a generic class's type arguments, where no class-level
[Arguments] row infers them, gave the source generator's instantiation the method row's values as
constructor arguments. Reflection mode built the instance from the class-level data instead.

The generator now takes the constructor arguments of such an instantiation from every class-level
data source, as for a [GenerateGenericTest] instantiation, converting them to the parameter types as
reflection mode does. Reflection mode now scopes method rows to the instantiation they infer for any
class-level data source, not only class-level [Arguments] rows, so a row inferring types a class-level
[MethodDataSource] does not yield runs on its own instantiation, as in the generator.

Fixes #7161
@coderabbitai

coderabbitai Bot commented Oct 20, 2026 •

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

🗂️ Base branches to auto review: 1
  • main

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration
  • Configuration used: Organization UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 2a4d91c3-2ad2-4ab6-936c-11c2e4debbec

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 20, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-20T05:43:55.975508Z 0489dfd PR opened
ℹ️ 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" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 0489dfd0af

ℹ️ 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".

@greptile-apps

greptile-apps Bot commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

RetriggerView in GreptileConfidence Score: 5/5

[Medium impact] The PR appears safe to merge; the previous array-construction issue is fixed.

Summary

Generic test classes now take constructor values from class data, not method rows.

  • Generic test instances use class data for constructors, not method rows.
  • Generic class argument rows infer their type from one value.

Reviews (3) · Last reviewed commit: "fix(sourcegen): build a generic class fr..." · Reviewed by Greptile

…d class rows

Reflection mode detected class-level [Arguments] rows with
IsDefined(typeof(ArgumentsAttribute)), which misses the generic
[Arguments<T>] form. The source generator matches both forms by name.
Reflection now checks the class data sources for either form.

The source generator also never inferred class type arguments from a
class-level [Arguments<T>(value)] row, so every instantiation of such a
class failed for lack of a class row. It now reads the value as a
one-value class row, as reflection mode does.

When a method row's instantiation takes its constructor arguments from
every class data source, reflection mode now reuses the rows discovery
already read, instead of evaluating the class data sources again in
TestBuilder.
…row's array, not its elements

The constructor-argument shortcut read any array first argument as the values of a params
[Arguments] row, so a class-level [Arguments<int[]>(new[] { 1, 2 })] row constructed the class with
1 and 2 instead of the array. It now applies only to the non-generic [Arguments], and the generic
row reaches the constructor through the test's class arguments, as other [Arguments<T>] rows do.
@github-actions

Copy link
Copy Markdown
Contributor

Review of #7192

I read the diff and did not run the tests. The skill-based review run failed, so this was done by hand.

Verdict: looks good, no blocking issues.

  • Source generator and reflection now agree. Method rows that infer types no class row infers build the instance from the class-level data sources in both modes. The generator no longer bakes a method row's values into the constructor, and EmitConcreteDataSources now hands such an instantiation every class data source.
  • Generic [Arguments<T>] handling is consistent. It is treated as a one-value class row both when inferring types and in the constructor guard (AttributeClass.IsGenericType: false). A null value infers no type.
  • No double enumeration in reflection. allClassRows reuses the rows discovery already read, via StaticDataSourceAttribute with PartitionOf, DeferEnumeration and SkipIfEmpty carried over, so a data source is never read twice. It is allocated only when it will be used.
  • Snapshots updated. The new Tests7161 verified file and the Tests7118 change (Activator.CreateInstance → typed new with CastHelper.Cast) match the intended behaviour.

Non-blocking suggestions

  1. HasClassArgumentsRows in the engine and the generator's HasClassRows / SpecificIsClassRow logic encode the same rule separately. A short comment on each pointing at the other would help stop them drifting apart. A shared helper is not possible across the two projects.
  2. allClassRows is materialised eagerly even when no method row ends up needing it. That is fine because it only applies to generic classes with method-row inference, but it is worth keeping in mind if discovery cost shows up in benchmarks.
  3. Confirm that the reflection-mode and AOT/trim paths are covered by the engine tests for the _7161 test project. The snapshot test only covers the source generator.

The PR is stacked on #7149, so merge order matters.

@thomhurst
thomhurst merged commit c4ee3b6 into fix/issue-7118-generic-row-pairing Oct 20, 2026
6 checks passed
@thomhurst
thomhurst deleted the fix/issue-7161-generic-ctor-from-class-data branch October 20, 2026 09:11
thomhurst added a commit that referenced this pull request Oct 20, 2026
…they infer (#7149)

* fix(engine): run method rows only on the generic class instantiation they infer

On a generic class with class-level [Arguments] rows, reflection mode ran
every method-level [Arguments] row on every class instantiation. A method
Test(T arg) with class rows (1) and ("a") and method rows (1) and ("a")
therefore produced 4 tests in reflection mode, one failing on argument
conversion, while source generation produced 2. Reflection mode now scopes
method rows the way the generator does. A row that infers the class's type
arguments runs only on that instantiation. Rows that infer none, such as
[Arguments(null)] or rows of other data sources, run on every instantiation.

A method row whose inferred types match no class row also differed between
the modes. Reflection mode ran it against the other class rows and failed on
conversion. The generator built an instance by passing the method row's own
values to the constructor. Both modes now report such a row as a failed test
that explains that no class row supplies its constructor arguments.

The issue's own repro, where the method has its own type parameter, already
gave the same 4 passing tests in both modes on main. It is kept as a
regression test.

Fixes #7118

* fix(engine): align generic row scoping edge cases across both modes

- Skip an instantiation whose method rows all infer other class types,
  instead of running the method once without arguments (both modes).
- Exempt only the types a class-level [GenerateGenericTest] lists from
  the missing-class-row failure, not every combination; reflection now
  reports that failure alongside [GenerateGenericTest] too.
- Let a class row's instantiation stand for a [GenerateGenericTest] of
  the same types in reflection mode, and scope method rows on a
  [GenerateGenericTest] instantiation that they infer, as the generator does.
- Apply the reflection scoping only with class-level [Arguments] rows,
  the condition the generator uses.
- Avoid the .NET 8 trim analyzer crash (AD0001) on an array write
  through `??=`, which broke the Windows build.
- Return the original data-source array when no row is filtered out.

* refactor(engine): name the class row group type and assert method row alignment

Replace the (Type[] TypeArguments, IDataSourceAttribute[] ClassDataSources, int RowCount) tuple, now spelled out
in three signatures, with a ClassRowGroup record struct. In ScopeMethodRowsToClassTypeArguments, add a
Debug.Assert so that a length mismatch between the two data source lists fails Debug builds instead of only
falling back to keeping every source.

* fix(sourcegen): let a matching class-level data source supply an instantiation that only a method row infers

When only method [Arguments] rows inferred an instantiation's class type
arguments, the generator marked it as having no class row, even if
another class-level data source could supply the constructor arguments.
Reflection groups those sources' rows by the types of their values and ran
the test on them, so the two modes disagreed.

A class-level typed source whose declared types infer the instantiation's
class type arguments is now added as its class row. A typed source of
another type does not count. Any other class data source is kept, because
the generator cannot see its row types.

* fix(generic): build generic class instances from class data, never from method rows (#7192)

* fix(generic): build generic class instances from class data, never from method rows

A method-level [Arguments] row that infers a generic class's type arguments, where no class-level
[Arguments] row infers them, gave the source generator's instantiation the method row's values as
constructor arguments. Reflection mode built the instance from the class-level data instead.

The generator now takes the constructor arguments of such an instantiation from every class-level
data source, as for a [GenerateGenericTest] instantiation, converting them to the parameter types as
reflection mode does. Reflection mode now scopes method rows to the instantiation they infer for any
class-level data source, not only class-level [Arguments] rows, so a row inferring types a class-level
[MethodDataSource] does not yield runs on its own instantiation, as in the generator.

Fixes #7161

* fix(generic): treat [Arguments<T>] as a class row and reuse discovered class rows

Reflection mode detected class-level [Arguments] rows with
IsDefined(typeof(ArgumentsAttribute)), which misses the generic
[Arguments<T>] form. The source generator matches both forms by name.
Reflection now checks the class data sources for either form.

The source generator also never inferred class type arguments from a
class-level [Arguments<T>(value)] row, so every instantiation of such a
class failed for lack of a class row. It now reads the value as a
one-value class row, as reflection mode does.

When a method row's instantiation takes its constructor arguments from
every class data source, reflection mode now reuses the rows discovery
already read, instead of evaluating the class data sources again in
TestBuilder.

* fix(sourcegen): build a generic class from an [Arguments<T[]>] class row's array, not its elements

The constructor-argument shortcut read any array first argument as the values of a params
[Arguments] row, so a class-level [Arguments<int[]>(new[] { 1, 2 })] row constructed the class with
1 and 2 instead of the array. It now applies only to the non-generic [Arguments], and the generic
row reaches the constructor through the test's class arguments, as other [Arguments<T>] rows do.

* fix(generic): keep typed null class rows and uninferred method rows on typed-source instantiations

- [Arguments<T>(null)] class row infers its declared T in both modes, so method
  rows of T run on it instead of failing for want of a class row.
- Source-gen: when a typed class data source joins an instantiation that only
  method rows inferred, method rows that infer no type (e.g. [Arguments(null)])
  join it too, as reflection runs them there.
- Source-gen: with class-level [Arguments] rows, drop instantiations that only a
  method data source's declared return type inferred (no row, no
  [GenerateGenericTest], no matching class source); reflection has none.

* fix(generic): map a typed null class row's declared type through the constructor parameter

[Arguments<string[]>(null)] on C<T>(T[] values) made reflection build C<string[]>
from the declared type, while source generation inferred no class from the row.
Both modes now match the declared type against the constructor's first parameter
(T, T[], or a constructed generic such as List<T>) and infer T = string.

Regression fixture ArrayParameterNullTypedClassRow runs in both modes via
Issue7118Tests; the analyzer gap (TUnit0001) and non-null rows are #7223.
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