Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,63 @@ Use the original method in memory:
var person = PersonMapper.MapPerson(employee);
```

## C# 14 extension members

C# 14 instance methods and expression-bodied getter-only properties in extension
blocks can generate ordinary static expression factories in the outer `static
partial` class. The receiver is the expression input; ordinary method arguments
become factory arguments.

```csharp
public static partial class OrderExtensions
{
// Classic extension method remains supported.
[Projectable]
public static decimal Twice(this Order order) => order.Amount * 2m;

extension(Order order)
{
[Projectable]
public decimal AmountWithTax => order.Amount * 1.25m;

[Projectable]
public string Label(int min) => order.Amount >= min ? "high" : "low";
}
}

// Expression<Func<Order, decimal>>
var tax = OrderExtensions.AmountWithTaxExpression();
// Expression<Func<Order, string>>
var label = OrderExtensions.LabelExpression(100);
```

Nested source-defined extension accesses/calls are expanded by symbol identity,
including classic/C# 14 composition, switch bodies, and nested LINQ method calls.
Primitive, interface, struct, nullable-value and reference receivers are supported.
`[Projectable]` on the outer class includes supported extension members.
`[Updatable]` and `[Adapt]` do not automatically apply to computed properties;
direct requests on extension methods receive `AM0021`.

The generator now requires a **Roslyn 5.0 or newer compiler host**. C# 14 consumers
require C# 14 language support; SDK **10.0.403 / Roslyn 5.9.0** is verified, including
fresh packaged consumers. Older target frameworks remain usable for classic
mappers with a compatible newer compiler host (verified `net8.0`, C# 12, SDK
10.0.403). Older Roslyn 4.x hosts are no longer supported. IDE builds must use a
compiler host meeting the same minimum; IDE versions were not separately tested.
The analyzer itself still targets `netstandard2.0`.

`AM0021` explains unsupported extension forms and skips the affected companion:
generic blocks/methods, block/accessor bodies, query syntax (use LINQ method
syntax), bound extension method groups (use an explicit lambda), static members,
optional/params/ref arguments, ref-like receivers, ref returns, generated factory
signature conflicts, and inaccessible expanded dependencies. Ordinary
`Select(Map)` method groups retain their existing support.

Null policies and the repeated-evaluation tradeoff remain unchanged. Generated
expression correctness does not guarantee provider translation; the SQLite
integration tests execute the documented scalar projection subset. See the
[extension compatibility matrix](docs/extension-members-compatibility-matrix.md).

## Switch expressions: evaluation semantics

AlephMapper lowers supported C# `switch` expressions to nested conditional
Expand Down
35 changes: 35 additions & 0 deletions docs/extension-members-autopilot-progress.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Extension members autopilot progress

- Starting main: `3fcc1a76b1bec0f4f6eacda49add93c8879e0350` (unchanged).
- PR #56 verified open/mergeable, CI run 41683392210 successful, all review threads resolved.
- Created dev_v0_8_0 from main, retargeted #56, squash-merged 2026-11-03 to `d154572081ba338c790f63f17605a98392dbe333`.
- Feature `feat/extension-members-parity-v0.8.0` created from that integration head in isolated worktree C:/Play/AlephMapper-extension-members. Owner untracked files untouched.
- Integration baseline: restore and Release build successful, 157 tests passed, 0 failed/skipped. Existing generated nullable warnings remain.
- SDK 10.0.403 hosts Roslyn 5.9.0. Independent net10.0 probe compiled against Roslyn 5.0.0 verifies CSharp14, ExtensionBlockDeclarationSyntax, IsExtension, ExtensionParameter, original symbols and consumer calls. Instance receiver is separate from ordinary method parameters. Generator minimum upgraded to 5.0.0 (netstandard2.0 retained).
- API: outer static class MemberExpression(extraArguments) returning Expression<Func<Receiver, Result>>. Properties use zero-argument factory. Extension properties receive Projectable support only.
- New PR MUST target dev_v0_8_0 and remain unmerged pending owner approval. No releases/tags/publishing.

## Implementation and review

- E00: 2ea9211, executable Roslyn probe; baseline158 including probe; package smoke passed.
- E01-E04: 7bebd06, discovery, separate receiver/member metadata, property catalog, transitive expansion and AM0021. Expanded receiver/cycle/determinism suite173 passed.
- E05-E07: b19767c, conversions, lambda alpha-renaming, static dependencies, escaped names and explicit unsupported forms. Initial bound-methodgroup negative fixture incorrectly used a value-type receiver (invalid CSharp delegate source); corrected to valid string receiver in f9f3f50, preserving assertions.
- E08: f9f3f50, nonfinite constants, qualified accessibility, query rejection and partial-class failure isolation. Full186 passed.
- Three bounded independent review cycles: all reported capture, static binding/accessibility, escaped name, conditional conversion, constants and query-capture findings fixed. Final cycle found nameof substitution; original INameOfOperation is now folded before substitution with runtime regression. No known findings remain; newest nameof fix has test evidence, not a fourth review.
- Only Projectable AttributeUsage changed in25 attribute snapshots. Existing generated mapper snapshots retained.
- Release/Debug builds and fresh packaged net10/CSharp14 and net8/classic consumers verified. Existing nullable warnings remain visible.
- New PR must target dev_v0_8_0 and remain OPEN pending owner approval; no auto-merge/release/tag/publication.

## Final local verification

- `dotnet restore AlephMapper.slnx`: pass.
- `dotnet build AlephMapper.slnx -c Release --no-restore`: pass, zero errors; existing nullable warnings visible.
- `dotnet build AlephMapper.slnx -c Debug --no-restore`: pass, zero errors; nullable warnings visible.
- `dotnet test AlephMapper.slnx -c Release --no-build --no-restore -- --report-trx --results-directory artifacts/test-results`: 186 passed, 0 failed, 0 skipped.
- `dotnet pack source/AlephMapper.csproj -c Release --no-restore -o artifacts/nuget`: pass, local0.7.4 artifact only (version unchanged, no publication).
- Fresh isolated package caches: CSharp14/net10 consumer restores and executes property/method/combined expression comparisons; classic CSharp12/net8 consumer restores and executes. No CS9057 or analyzer assembly-load failures. Both use the final packed analyzer without direct Roslyn references.
- `git diff --check`: pass.
- Final review fix commit: 0f7f516 (nameof). No known unresolved findings after three bounded review cycles.
- Main remains3fcc1a76b1bec0f4f6eacda49add93c8879e0350; integration remainsd154572081ba338c790f63f17605a98392dbe333.

Owner gate: extension-members PR has NOT been merged. Await explicit approval to merge into dev_v0_8_0. CI and remote review status are reported in the PR and final handoff separately from local checks.
58 changes: 58 additions & 0 deletions docs/extension-members-compatibility-matrix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# Extension member compatibility matrix

Scope: readable source-generated projection/inlining for C#14 instance extension
methods and getter-only expression properties. Factories are static methods in the
outer partial class. This is not full ExpressiveSharp parity.

Evidence: [generator tests](../tests/AlephMapper.Tests/ExtensionMemberTests.cs),
[runtime and SQLite tests](../tests/AlephMapper.IntegrationTests/ExtensionMembersParityTests.cs).

| Case / source example | Expected API | Compile | Runtime | SQLite | Status / evidence or reason |
|---|---|---|---|---|---|
| `extension(Order o) { decimal AmountWithTax => ...; }` | `AmountWithTaxExpression()` | Pass | Pass | Pass | CSharp14_properties_methods_and_classic_extensions_compose; CSharp14_projection_executes_in_SQLite |
| `Label(int min)` | `LabelExpression(int min)` | Pass | Pass | Pass | Same, below/equal/above threshold |
| Primitive `int.Squared` | `SquaredExpression()` | Pass | Pass | Not asserted | CSharp14_extension_factories_compile_and_match_source; runtime composition |
| Interface `IEntity.InterfaceLabel` | `InterfaceLabelExpression()` | Pass | Pass | Not asserted | Runtime composition |
| Struct `Point.Sum` | `SumExpression()` | Pass | Pass | Not asserted | Extension_members_preserve_nested_receivers_null_policies_and_method_groups |
| Nullable `int?.OrZero` | `OrZeroExpression()` | Pass | Pass | Not asserted | Same, null/non-null |
| Named/reordered/mixed arguments | Symbol/operation binding | Pass | Pass | Not asserted | Named mapper, including positional after named |
| Overloads with different formal types | `TimesExpression(int)` and `(long)` | Pass | Pass | Not asserted | Composition runtime |
| Same factory signature across receiver blocks | Skip conflicting factories | Pass | N/A | N/A | AM0021; Extension_factory_signature_conflicts_are_diagnosed |
| Separate namespaces / same property name | Separate property symbols | Pass | Not separately run | N/A | Extension_members_resolve_by_symbol_across_namespaces; no residual .Value |
| Property/method and classic/CSharp14 composition | Transitive expansion | Pass | Pass | Pass subset | Composed, Classic, ToDto |
| Nested receivers and composite return precedence | Correct path and grouping | Pass | Pass | Not asserted | Nested mapper, scalar fixtures |
| Switch bodies and guards | Existing switch lowering | Pass | Pass | Pass | Label/ToDto plus full PR56 regression suite |
| `Select(Map)` with extension properties in helper | Existing method-group expansion | Pass | Pass | Not asserted | Group mapper; classic generated mapper snapshots unchanged |
| Bound extension method group | Skip companion; use explicit lambda | Pass | N/A | N/A | AM0021; Bound_extension_method_groups_report_diagnostic |
| Ignore / Rewrite null strategy | Existing behavior plus receiver conversion | Pass | Pass | Not asserted | Nested receivers and Base/Derived hidden member regression |
| None null strategy | Skip expression | Pass | N/A | N/A | AM0017; NullConditional_None_keeps_its_diagnostic_for_extension_members |
| Lambda name collision | Symbol-based alpha-renaming | Pass | Pass | Not asserted | Capture returns14, not5 |
| Static dependencies, nonfinite constants, nameof | Bound symbols / original constant value | Pass | Pass | Not asserted | StaticDependency, Nonfinite, Name runtime regressions |
| Inaccessible qualified/unqualified dependency | Skip affected companion | Pass | N/A | N/A | AM0021; Inaccessible_extension_dependencies_are_diagnosed |
| Escaped receiver/factory argument | Escaped identifiers | Pass | Not separately run | N/A | Escaped_receiver_and_factory_argument_names_compile |
| Partial files, class/member attributes, repeat run | One deterministic file | Pass | N/A | N/A | Extension_members_in_partial_files_generate_once_and_deterministically |
| Method/property cycles | Terminate safely | Pass | N/A | N/A | AM0002; Extension_member_cycles_terminate_with_diagnostic |
| Missing partial outer class | No invalid partial source | Pass | N/A | N/A | AM0021; Missing_partial_outer_class_has_actionable_diagnostic |
| Direct Updatable / Adapt extension request | Projection only, diagnostic for unsupported request | Pass | N/A | N/A | AM0021; direct Updatable regression, ordinary Update/Adapt suite retained |
| Generic blocks/methods | No assumed generic substitution | Pass method fixture | N/A | N/A | AM0021 |
| Optional/params/ref args, ref-like receiver, ref returns | Explicit unsupported reason | Tested optional | N/A | N/A | AM0021; remaining shapes symbol-validated, no exhaustive test claim |
| Block/accessor bodies, static extension members | Skip affected companion | Pass | N/A | N/A | AM0021; unsupported-member matrix |
| Query syntax / block or async lambda | Use LINQ method syntax / expression lambda | Pass query/block fixtures | N/A | N/A | AM0021; prevents range-variable capture or invalid expression trees |
| Cross-assembly source body expansion | Not attempted | N/A | N/A | N/A | Source body required; unexpandable dependency AM0021 |

Matrix reports test evidence instead of a marketing parity percentage. Receiver
and argument evaluation may be repeated, skipped or reordered by the existing
conditional/substitution architecture; no AM0020 or purity gate was introduced.
Property catalog keys are IPropertySymbol identities; getter methods are stable
recursion tokens alongside the existing method catalog, not unrelated methods.

Compiler minimum: Roslyn5.0+ (independent API probe). Verified installed host:
SDK10.0.403/Roslyn5.9.0. Fresh packaged CSharp14/net10 and classic CSharp12/net8
consumers execute successfully without analyzer-load failures. IDE builds must
meet the same host requirement; IDE versions were not separately tested.
Older Roslyn4.x hosts are incompatible. Generator target stays netstandard2.0.

Conceptual inventory: [ExpressiveSharp guide](https://github.com/EFNext/ExpressiveSharp/blob/main/docs/guide/extension-members.md)
and [reference tests](https://github.com/EFNext/ExpressiveSharp/blob/main/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/ExtensionMemberTests.cs).
Upstream test code was not copied or executed. Block-body lowering, interceptors,
ExpressiveFor and cross-assembly source reuse remain outside scope.
15 changes: 15 additions & 0 deletions docs/v0.8.0-release-notes-draft.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# v0.8.0 release notes - unpublished draft

- Add CSharp14 extension-block instance methods and expression-bodied getter
properties to static Projectable factories, with symbol-aware composition.
- Add AM0021 for unsupported requests, signature conflicts and inaccessible
dependencies; existing switch/null diagnostic contracts remain unchanged.
- Preserve receiver/argument conversions, nested lambda scopes, static dependencies,
original nameof results and classic extension method behavior.
- Compiler compatibility change: Roslyn5.0+ analyzer host required; verified with
SDK10.0.403/Roslyn5.9.0. Classic mappings targeting net8/CSharp12 still work with
the newer host. Generator target remains netstandard2.0.
- Incorporates PR56 switch and helper-inlining improvements in dev_v0_8_0.

No version bump, release, tag, NuGet publish or main integration was performed.
The feature PR requires explicit owner approval before merge.
2 changes: 1 addition & 1 deletion source/AlephMapper.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.14.0" PrivateAssets="all" />
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="5.0.0" PrivateAssets="all" />
<PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="3.11.0">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
Expand Down
2 changes: 2 additions & 0 deletions source/AnalyzerReleases.Unshipped.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,5 @@ AM0015 | AlephMapper | Error | Adapted generated member fails Roslyn rebinding.
AM0016 | AlephMapper | Warning | Null-conditional receiver cannot be safely rewritten.
AM0017 | AlephMapper | Warning | Null-conditional access is unsupported in expression trees.
AM0018 | AlephMapper | Warning | Adaptation of generic mapping methods is unsupported.

AM0021 | AlephMapper | Warning | Extension member cannot be projected; diagnostic supplies the unsupported reason.
2 changes: 1 addition & 1 deletion source/Attributes.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ internal enum NullConditionalRewrite
/// Marks a class to generate projectable companion methods.
/// </summary>
[Embedded]
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false, Inherited = false)]
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method | AttributeTargets.Property, AllowMultiple = false, Inherited = false)]
internal sealed class ProjectableAttribute : Attribute
{
/// <summary>
Expand Down
6 changes: 6 additions & 0 deletions source/Diagnostics/DiagnosticDescriptors.cs
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,11 @@ public static class DiagnosticDescriptors
DiagnosticSeverity.Warning,
isEnabledByDefault: true);

public static readonly DiagnosticDescriptor UnsupportedExtensionMember = new(
"AM0021", "Extension member cannot be projected",
"Expression generation for extension member '{0}' skipped: {1}",
"AlephMapper", DiagnosticSeverity.Warning, isEnabledByDefault: true);

public static DiagnosticDescriptor? GetById(string id) => id switch
{
"AM0001" => UpdatableValueTypeReturn,
Expand All @@ -192,6 +197,7 @@ public static class DiagnosticDescriptors
"AM0017" => UnsupportedNullConditionalExpression,
"AM0018" => AdaptGenericMethodUnsupported,
"AM0019" => UnsupportedSwitchPattern,
"AM0021" => UnsupportedExtensionMember,
_ => null
};
}
7 changes: 7 additions & 0 deletions source/Generation/Emitters/AdaptationMemberEmitter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,13 @@ public static void Emit(MappingMethodDetails details, MapperGenerationContext co
.WithoutLeadingTrivia()
.WithoutTrailingTrivia();
context.AddUsings(inliner.UsingDirectives.Concat(mapping.UsingDirectives));
if (inliner.UnsupportedExtensions.FirstOrDefault() is var unsupportedExtension && unsupportedExtension.Member is not null)
{
context.ReportDiagnostic(Diagnostic.Create(DiagnosticDescriptors.UnsupportedExtensionMember,
unsupportedExtension.Member.Locations.FirstOrDefault(), unsupportedExtension.Member.Name, unsupportedExtension.Reason));
continue;
}

if (inliner.CircularReferences.Any())
{
context.ReportDiagnostic(Diagnostic.Create(
Expand Down
18 changes: 18 additions & 0 deletions source/Generation/Emitters/ProjectableMemberEmitter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,13 @@ public static void Emit(MappingMethodDetails details, MapperGenerationContext co
.WithoutTrailingTrivia();
context.AddUsings(inliner.UsingDirectives.Concat(mapping.UsingDirectives));

if (inliner.UnsupportedExtensions.FirstOrDefault() is var unsupportedExtension && unsupportedExtension.Member is not null)
{
context.ReportDiagnostic(Diagnostic.Create(DiagnosticDescriptors.UnsupportedExtensionMember,
unsupportedExtension.Member.Locations.FirstOrDefault(), unsupportedExtension.Member.Name, unsupportedExtension.Reason));
return;
}

if (inliner.CircularReferences.Any())
{
context.ReportDiagnostic(Diagnostic.Create(
Expand Down Expand Up @@ -59,6 +66,17 @@ public static void Emit(MappingMethodDetails details, MapperGenerationContext co
}

var expressionMethodName = mapping.Name + "Expression";
if (mapping.ReceiverSymbol is not null &&
(context.GeneratedMemberSignatures.Contains(MethodSignature.Build(expressionMethodName,
details.ExtraExpressionParameterTypeNames, details.MethodTypeParameterCount)) ||
context.MapperType.GetMembers(expressionMethodName).OfType<IMethodSymbol>().Any(m =>
m.Parameters.Select(p => p.Type.ToDisplayString()).SequenceEqual(mapping.Parameters.Skip(1).Select(p => p.Type.ToDisplayString())))))
{
context.ReportDiagnostic(Diagnostic.Create(DiagnosticDescriptors.UnsupportedExtensionMember,
mapping.MemberSymbol.Locations.FirstOrDefault(), mapping.Name, "the generated factory signature conflicts with another member"));
return;
}

context.GeneratedMemberSignatures.Add(MethodSignature.Build(
expressionMethodName,
details.ExtraExpressionParameterTypeNames,
Expand Down
3 changes: 3 additions & 0 deletions source/Generation/MapperCandidate.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using Microsoft.CodeAnalysis.CSharp.Syntax;
using System;
using System.Threading;
using System.Linq;

namespace AlephMapper.Generation;

Expand Down Expand Up @@ -38,6 +39,8 @@ public static bool IsAttributeTarget(SyntaxNode node, CancellationToken _)
{
ClassDeclarationSyntax classDeclaration => classDeclaration,
MethodDeclarationSyntax { Parent: ClassDeclarationSyntax classDeclaration } => classDeclaration,
MethodDeclarationSyntax { Parent: ExtensionBlockDeclarationSyntax } or
PropertyDeclarationSyntax { Parent: ExtensionBlockDeclarationSyntax } => node.Ancestors().OfType<ClassDeclarationSyntax>().FirstOrDefault(),
_ => null
};

Expand Down
Loading
Loading