The type under analysis
The reference console app ships this pair. Two parameters are forwarded to the base constructor; one of them is also assigned locally.
public class BaseEntity
{
public Guid Id { get; }
public string Name { get; }
protected BaseEntity(Guid id, string name)
{
Id = id;
Name = name;
}
}
public class User : BaseEntity
{
public string Email { get; }
public string Username { get; }
public User(string email, Guid userId, string userName)
: base(userId, userName)
{
Email = email;
Username = userName;
}
}
Reflection does not tell you that userName reaches two public properties, including one declared on the base type, or that userId travels through base(...) into BaseEntity.Id.
For a code generator, those relationships matter. It needs to connect email to Email, preserve both uses of userName, and pass the inherited values through the base call. Matching names alone misses relationships such as userId → Id.
Runtime constructor analysis supplies distinctive values, constructs the object once, and inspects its public state. When a property preserves a parameter's sentinel, that parameter is Inferred. The result gives a generator a parameter-to-property map without needing the original C# source. The sample targets data classes with straightforward assignments; it observes values, not every possible execution path.
How the probe runs
- Build a sentinel for every parameter before invoking anything. Strings, guids, numbers, enums, classes and interfaces each get an assignable distinctive value when the type's domain allows it.
- Invoke the selected constructor once with that complete argument vector. The analyzer does not fill unused slots with
null, and it does not construct once per parameter.
- Read every public, non-indexed instance property. Exact matches use ordinal string equality, value equality for value types, and reference identity for class and interface sentinels. A string that contains the complete sentinel without remaining identical to it is the exceptional heuristic containment path.
- Probe the immediate base separately only when it exposes exactly one accessible constructor in total and that constructor has parameters. A shared property links a derived parameter to an ordered base parameter. A get-only auto-property rules out an ordinary overwrite by the derived constructor, so the sample reports an inferred base parameter. Writable properties retain a heuristic candidate. This correlation assumes the base constructor uses each parameter consistently across the two probes; neither verdict changes the original property mapping.
The interactive figure replays that single construction one parameter at a time. Playback only changes which exact landing points are highlighted.
Read the result in three layers
Every parameter receives a ParameterInferenceOutcome:
| Outcome |
Meaning |
Inferred |
One or more property mappings preserved the sentinel. |
Unmatched |
The sentinel was supported and construction succeeded, but no readable property preserved it. |
Ambiguous |
A unique conclusion is unsafe, as with Boolean or colliding finite-domain values. |
Unsupported |
No safe assignable, collision-resistant sentinel exists for that shape. |
InstantiationFailed |
Binding, access, support, or constructor execution failed. |
InspectionFailed |
Construction succeeded, but a public getter threw while the object was inspected. |
An Inferred parameter then exposes each property mapping's confidence and provenance. ExactSentinel means the observed property preserved the supplied sentinel exactly. TransformedStringContainment is a separate Heuristic result.
Direct-base attribution is a third result. It asks which base-constructor parameter received a forwarded argument. ReadOnlyBaseSentinel marks an Inferred base parameter with Exact confidence: both probes matched the same get-only base property. Here Exact describes the observed match; the argument position is inferred under the assignment-based constructor assumption. DirectBasePropertyCorrelation marks an Ambiguous candidate with Heuristic confidence: the property overlaps, but the derived constructor could have written it. In both cases the property observation that led there remains exact.
What the console app prints
This is the User section of dotnet run --project src/Demo.ConsoleApp. DemoConsoleOutputTests.UserTranscriptExposesParameterPropertyAndDirectBaseOutcomes locks the block.
Type: User
Constructor Parameters:
- String email
- Guid userId
- String userName
Parameter Flow Analysis:
Parameter: email (String)
Parameter outcome: Inferred
Assigned to properties:
→ User.Email (Confidence: Exact; Provenance: ExactSentinel)
Parameter: userId (Guid)
Parameter outcome: Inferred
Assigned to properties:
→ BaseEntity.Id (Confidence: Exact; Provenance: ExactSentinel)
Direct-base outcome: Inferred
→ Base parameter [0] id via BaseEntity.Id (Outcome: Inferred; Confidence: Exact; Provenance: ReadOnlyBaseSentinel)
Parameter: userName (String)
Parameter outcome: Inferred
Assigned to properties:
→ BaseEntity.Name (Confidence: Exact; Provenance: ExactSentinel)
→ User.Username (Confidence: Exact; Provenance: ExactSentinel)
Direct-base outcome: Inferred
→ Base parameter [1] name via BaseEntity.Name (Outcome: Inferred; Confidence: Exact; Provenance: ReadOnlyBaseSentinel)
Properties set in constructor:
- BaseEntity.Id
- BaseEntity.Name
- User.Email
- User.Username
Open the reference implementation and reproduce that block. Install the .NET 10 SDK first. There is no NuGet package; src/ConstructorAnalysis is the library in this repo, and src/Demo.ConsoleApp is the playground the transcript comes from.
git clone https://github.com/meta-engine/constructor-analysis
cd constructor-analysis
dotnet test constructor-analysis.sln --configuration Release
dotnet run --project src/Demo.ConsoleApp --configuration Release
Read the property rows first, then the narrower base rows. email has no direct-base line because it never reached BaseEntity.
How direct-base candidates are decided
Run the same one-vector probe on BaseEntity's constructor. userName preserves its sentinel in BaseEntity.Name during the User construction; base parameter name preserves its own sentinel in that same reflected property during the base probe. That links userName to base parameter [1] name.
A link alone is not proof. If Name had a setter, the User constructor could have called base(userId, "constant") and then written Name = userName itself, and the two probes would look identical. The analyzer therefore checks the property's shape through reflection. A get-only auto-property has no set accessor and a private initonly compiler-generated backing field, which verifiable code can write only inside a constructor of the declaring type. That rules out an ordinary derived write, so the sample labels the correlation Inferred, Exact, ReadOnlyBaseSentinel. It does not establish how the base constructor behaves for other inputs. A base constructor that conditionally chooses between parameters can invalidate the inferred position even when the property is get-only; the analyzer does not inspect the constructor body to rule that out.
A property with a setter, an init accessor, or a getter over a protected field keeps the heuristic verdict. init accessors are callable from derived constructors, and a protected field can be written from anywhere in the hierarchy, so those shapes stay Ambiguous, Heuristic, DirectBasePropertyCorrelation, and the console app prints them with a ? marker instead of an arrow.
The demo's third example makes the separation visible with misleading names: Employee(..., Role userRole, ...) : base(..., userRole), with AccessLevel = userRole locally. The app reports both Employee.AccessLevel and Person.Role as exact property mappings, then reports base parameter [2] role as an inferred base parameter because Person.Role is get-only.
Where the technique stops
- Transformed values that drop the sentinel. Case changes and concatenation that retain the complete string can still match heuristically. A transform that removes, splits, reorders, truncates, encodes, or hashes the sentinel remains unmatched.
- Finite domains. Boolean flow needs a contrast probe to distinguish assignment from a constant. The reference marks booleans and exhausted narrow domains ambiguous. Numeric and enum sentinels can also coincide with a constant in the class, so an exact value match alone does not prove an assignment caused it.
- Writable inherited state. A base property with a setter, an
init accessor, or a protected backing field could have been written by the derived constructor, so its base candidate stays heuristic. The read-only verdict assumes verifiable code; reflection can still write an initonly field.
- Constructors that reject the generated vector. Failures are reported as
InstantiationFailed. The reference implementation does not retry with a different sentinel.
- Shapes without a safe sentinel. Custom structs and some abstract or delegate references are unsupported.
null is never accepted as flow evidence.
- Observable public state only. Public readable, non-indexed properties are inspected, including computed getters. A throwing getter is reported as
InspectionFailed; private getters and fields are not inspected.
- Side effects run. A constructor that opens a connection opens it. Keep the technique on DTO-shaped types.
- One constructor, one base level. The analyzer selects the constructor with the most parameters and inspects
type.BaseType once. Multiple accessible base constructors make direct-base analysis ambiguous. For A : B : C, flow into C is not traced.
Where it fits
The mapping is useful before rendering begins. A generator can distinguish local assignments from inherited values, retain a parameter that feeds more than one property, and emit names according to the target language's conventions. Templates can render those decisions once the relationships are known; observation supplies information that a constructor signature alone does not contain.
The reference repository isolates the observation technique so it is easy to inspect and adapt. A generator can combine these observations with explicit configuration, then pass the resulting relationships to its target-language renderer.
Change the examples under src/Demo.ConsoleApp/Examples and run the playground again. Start with the data classes you would actually want to generate.