Skip to content
Merged
Show file tree
Hide file tree
Changes from 18 commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
a294bd2
docs: implementation plan for v1.7 Feature 3 ([ExtractProperty]/[Wrap…
superyyrrzz Apr 21, 2026
c99f3e5
feat(v1.7): add [ExtractProperty] and [WrapProperty] attributes
superyyrrzz Apr 21, 2026
ebfe281
feat(v1.7): register FM0065-FM0071, FM0074 diagnostics for ExtractPro…
superyyrrzz Apr 21, 2026
82f6159
feat(v1.7): add attribute-symbol fields and detection helpers for Ext…
superyyrrzz Apr 21, 2026
dd04e21
feat(v1.7): scaffold ExtractWrap module with FM0065 and FM0070 valida…
superyyrrzz Apr 21, 2026
baad397
feat(v1.7): emit [ExtractProperty] body with null-guard and built-in …
superyyrrzz Apr 21, 2026
3fe14ef
feat(v1.7): emit [WrapProperty] body with strategy selection and tie-…
superyyrrzz Apr 21, 2026
07d253d
feat(v1.7): dispatch [ExtractProperty]/[WrapProperty] from GenerateMe…
superyyrrzz Apr 21, 2026
03eb234
test(v1.7): cover [ExtractProperty]/[WrapProperty] across diagnostics…
superyyrrzz Apr 21, 2026
ca080f4
chore(v1.7): mark Features 1 and 3 as implemented in spec status table
superyyrrzz Apr 21, 2026
9d92037
fix(v1.7): tighten extract/wrap accessor checks and add DateTime→Date…
superyyrrzz Apr 22, 2026
7924ead
fix(v1.7): force UTC kind in DateTime→DateTimeOffset wrap to mirror e…
superyyrrzz Apr 22, 2026
c6ee1db
fix(v1.7): support nullable DateTime↔DateTimeOffset in wrap coercion …
superyyrrzz Apr 22, 2026
224bde3
fix(v1.7): walk inheritance chain in extract/wrap property and requir…
superyyrrzz Apr 22, 2026
c504243
fix(v1.7): handle Nullable<T> source in extract/wrap, required fields…
superyyrrzz Apr 22, 2026
c0ad400
chore: remove stray scratch files accidentally committed
superyyrrzz Apr 22, 2026
45ccc13
fix(v1.7): respect StringToEnum config and short-circuit on FM0013 am…
superyyrrzz Apr 22, 2026
d561d5c
fix(v1.7): don't suppress required-member check by ctor param name
superyyrrzz Apr 22, 2026
2575248
fix(v1.7): honor [ForgeConstructor] in WrapProperty ctor selection
superyyrrzz Apr 22, 2026
8f7560d
docs(diagnostics): clarify FM0068 message to describe wrap-strategy v…
superyyrrzz Apr 22, 2026
6d62503
docs(analyzer-releases): sort FM* rule IDs in ascending order
superyyrrzz Apr 22, 2026
26a3b10
fix(v1.7): clarify FM0065, hard-fail [ForgeConstructor] mismatch, doc…
superyyrrzz Apr 22, 2026
3cc253f
fix(v1.7): emit FM0068/FM0069 when [ForgeConstructor] mismatches wrap…
superyyrrzz Apr 22, 2026
6ebab48
fix(v1.7): defer ctor selection when initializer wrap path is viable
superyyrrzz Apr 22, 2026
2b29529
test(v1.7): regression test for [ForgeConstructor()] + viable wrap in…
superyyrrzz Apr 22, 2026
b8e3260
fix(v1.7): emit FM0069 (not FM0068) when wrap ctor param exists with …
superyyrrzz Apr 22, 2026
e36a226
fix(v1.7): reject abstract destinations in [WrapProperty] with FM0068
superyyrrzz Apr 22, 2026
a0e82d3
fix(wrap): honor parameterized [ForgeConstructor] and validate other-…
superyyrrzz Apr 22, 2026
e31bd45
fix(wrap): explicit parameterized [ForgeConstructor] never silently f…
superyyrrzz Apr 22, 2026
b1689be
chore: remove stray Copilot CLI scratch files
superyyrrzz Apr 22, 2026
ebb7e84
fix(wrap): init strategy must verify type compat with named property
superyyrrzz Apr 22, 2026
3b5ee50
fix(wrap): trust [SetsRequiredMembers] on parameterless ctor for init…
superyyrrzz Apr 22, 2026
e4974d7
fix(extract): emit FM0007 when Nullable<T> property maps to non-nulla…
superyyrrzz Apr 22, 2026
6d591ac
fix(extract/wrap): suppress FM0074 for Nullable<T> returns; clarify F…
superyyrrzz Apr 22, 2026
2d2ca4a
fix(extract-wrap): accept set-only init targets and route empty [Forg…
superyyrrzz Apr 22, 2026
d3d23ed
fix(extract-wrap): scope FM0069 ctor scan to honor explicit [ForgeCon…
superyyrrzz Apr 22, 2026
1aa4712
fix(extract-wrap): exclude non-viable ctors from FM0069 type-mismatch…
superyyrrzz Apr 22, 2026
49e1f99
fix(extract): emit FM0007 for nullable-reference → non-nullable-refer…
superyyrrzz Apr 22, 2026
5953cdc
fix(generator): reject interface destinations in WrapProperty as FM0068
superyyrrzz Apr 22, 2026
6944538
fix(generator): emit FM0066/FM0068 for null/empty Extract/Wrap proper…
superyyrrzz Apr 22, 2026
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
4 changes: 2 additions & 2 deletions docs/SPEC-v1.7-projection-and-conditional.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@ v1.7 closes three AutoMapper-parity gaps surfaced by the [Duende.IdentityServer.

| # | Feature | Issue | Effort | Status |
|---|---------|-------|--------|--------|
| 1 | Per-property LINQ projection (`SelectProperty`) | [#125](https://github.com/superyyrrzz/ForgeMap/issues/125) | Low | Planned |
| 1 | Per-property LINQ projection (`SelectProperty`) | [#125](https://github.com/superyyrrzz/ForgeMap/issues/125) | Low | Implemented |
| 2 | Conditional property assignment (`Condition`, `SkipWhen`) | [#126](https://github.com/superyyrrzz/ForgeMap/issues/126) | Medium | Implemented |
| 3 | Entity↔primitive mapping (`[ExtractProperty]`, `[WrapProperty]`) | [#127](https://github.com/superyyrrzz/ForgeMap/issues/127) | Medium | Planned |
| 3 | Entity↔primitive mapping (`[ExtractProperty]`, `[WrapProperty]`) | [#127](https://github.com/superyyrrzz/ForgeMap/issues/127) | Medium | Implemented |

Features 1 and 3 compose naturally: `SelectProperty` extracts a primitive from each element in a collection, while `[ExtractProperty]` / `[WrapProperty]` handle the single-element case. Both target the same join-table-entity pattern from opposite ends.

Expand Down
1,636 changes: 1,636 additions & 0 deletions docs/superpowers/plans/2026-04-21-v1.7-extract-wrap-property.md

Large diffs are not rendered by default.

28 changes: 28 additions & 0 deletions src/ForgeMap.Abstractions/ExtractPropertyAttribute.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
using System;

namespace ForgeMap;

/// <summary>
/// Marks a partial forge method that returns a single property of the source object.
/// The method must have signature <c>partial TPrimitive MethodName(TEntity source)</c>.
/// The generator emits a null-guard governed by <c>NullHandling</c>, then returns
/// <c>source.PropertyName</c> (with built-in coercion if needed for the declared return type).
/// Available in ForgeMap v1.7+.
/// </summary>
[AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = false)]
public sealed class ExtractPropertyAttribute : Attribute
{
/// <summary>
/// Creates a new <see cref="ExtractPropertyAttribute"/>.
/// </summary>
/// <param name="propertyName">Name of the readable instance property on the source type to return.</param>
public ExtractPropertyAttribute(string propertyName)
{
PropertyName = propertyName ?? throw new ArgumentNullException(nameof(propertyName));
}

/// <summary>
/// Gets the name of the source property to extract.
/// </summary>
public string PropertyName { get; }
}
31 changes: 31 additions & 0 deletions src/ForgeMap.Abstractions/WrapPropertyAttribute.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
using System;

namespace ForgeMap;

/// <summary>
/// Marks a partial forge method that constructs a new destination object from the source primitive
/// by assigning or binding it to the named property. The method must have signature
/// <c>partial TEntity MethodName(TPrimitive source)</c>. The destination type must either expose a
/// constructor parameter of that name, or expose a settable (<c>set</c> or <c>init</c>) property of
/// that name AND a public parameterless constructor. The generator emits the appropriate
/// construction form (<c>new TEntity { Prop = source }</c> or <c>new TEntity(prop: source)</c>),
/// with null-guarding governed by <c>NullHandling</c>.
Comment thread
superyyrrzz marked this conversation as resolved.
Outdated
/// Available in ForgeMap v1.7+.
/// </summary>
[AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = false)]
public sealed class WrapPropertyAttribute : Attribute
{
/// <summary>
/// Creates a new <see cref="WrapPropertyAttribute"/>.
/// </summary>
/// <param name="propertyName">Name of the destination property or constructor parameter to assign.</param>
public WrapPropertyAttribute(string propertyName)
{
PropertyName = propertyName ?? throw new ArgumentNullException(nameof(propertyName));
}

/// <summary>
/// Gets the name of the destination property or constructor parameter to assign.
/// </summary>
public string PropertyName { get; }
}
8 changes: 8 additions & 0 deletions src/ForgeMap.Generator/AnalyzerReleases.Unshipped.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,11 @@ FM0064 | ForgeMap | Disabled | ConditionalAssignmentApplied
FM0072 | ForgeMap | Error | SelectPropertyConflictsWithForgeFromOrWith
FM0073 | ForgeMap | Error | SelectPropertyDestinationNotEnumerable
FM0075 | ForgeMap | Warning | SelectPropertyNotSupportedOnForgeInto
FM0065 | ForgeMap | Error | ExtractWrapConflictsWithMethodAttributes
FM0066 | ForgeMap | Error | ExtractPropertyNotFound
FM0067 | ForgeMap | Error | ExtractPropertyTypeIncompatible
FM0068 | ForgeMap | Error | WrapPropertyNotFound
FM0069 | ForgeMap | Error | WrapPropertyTypeIncompatible
FM0070 | ForgeMap | Error | ExtractWrapInvalidSignature
FM0071 | ForgeMap | Error | WrapPropertyRequiredMembersUnsatisfied
FM0074 | ForgeMap | Disabled | ExtractWrapValueTypeReturnUnderReturnNull
Comment thread
superyyrrzz marked this conversation as resolved.
Outdated
64 changes: 64 additions & 0 deletions src/ForgeMap.Generator/DiagnosticDescriptors.cs
Original file line number Diff line number Diff line change
Expand Up @@ -505,6 +505,70 @@ internal static class DiagnosticDescriptors
defaultSeverity: DiagnosticSeverity.Warning,
isEnabledByDefault: true);

public static readonly DiagnosticDescriptor ExtractWrapConflictsWithMethodAttributes = new(
id: "FM0065",
title: "[ExtractProperty]/[WrapProperty] cannot combine with [ConvertWith], [ForgeFrom], [ForgeWith], or [ForgeProperty]",
messageFormat: "Method '{0}' has conflicting attributes: [ExtractProperty]/[WrapProperty] cannot combine with [ConvertWith], [ForgeFrom], [ForgeWith], or [ForgeProperty]",
Comment thread
superyyrrzz marked this conversation as resolved.
Outdated
category: Category,
defaultSeverity: DiagnosticSeverity.Error,
isEnabledByDefault: true);

public static readonly DiagnosticDescriptor ExtractPropertyNotFound = new(
id: "FM0066",
title: "[ExtractProperty] target property not found on source type",
messageFormat: "[ExtractProperty(\"{0}\")] not found on source type '{1}' for method '{2}', or not a public readable instance property",
category: Category,
defaultSeverity: DiagnosticSeverity.Error,
isEnabledByDefault: true);

public static readonly DiagnosticDescriptor ExtractPropertyTypeIncompatible = new(
id: "FM0067",
title: "[ExtractProperty] source property type incompatible with method return type",
messageFormat: "[ExtractProperty] source property type '{0}' is incompatible with method return type '{1}' for method '{2}', and no supported coercion applies",
category: Category,
defaultSeverity: DiagnosticSeverity.Error,
isEnabledByDefault: true);

public static readonly DiagnosticDescriptor WrapPropertyNotFound = new(
id: "FM0068",
title: "[WrapProperty] target not found on destination type",
messageFormat: "[WrapProperty(\"{0}\")] not found as settable/init property or constructor parameter on destination type '{1}' for method '{2}'",
Comment thread
superyyrrzz marked this conversation as resolved.
Outdated
category: Category,
defaultSeverity: DiagnosticSeverity.Error,
isEnabledByDefault: true);

public static readonly DiagnosticDescriptor WrapPropertyTypeIncompatible = new(
id: "FM0069",
title: "[WrapProperty] source parameter type incompatible with destination property/parameter type",
messageFormat: "[WrapProperty] source parameter type '{0}' is incompatible with destination property/parameter type '{1}' for method '{2}', and no supported coercion applies",
category: Category,
defaultSeverity: DiagnosticSeverity.Error,
isEnabledByDefault: true);

public static readonly DiagnosticDescriptor ExtractWrapInvalidSignature = new(
id: "FM0070",
title: "[ExtractProperty]/[WrapProperty] partial method has invalid signature",
messageFormat: "[ExtractProperty]/[WrapProperty] partial method '{0}' has invalid signature — must have exactly one parameter and a non-void return type",
category: Category,
defaultSeverity: DiagnosticSeverity.Error,
isEnabledByDefault: true);

public static readonly DiagnosticDescriptor WrapPropertyRequiredMembersUnsatisfied = new(
id: "FM0071",
title: "[WrapProperty] cannot satisfy required members of destination type",
messageFormat: "[WrapProperty] cannot construct '{0}' because these required members are unsatisfied: {1}. Add a constructor that accepts these members, mark them init without required, or write the partial body manually.",
category: Category,
defaultSeverity: DiagnosticSeverity.Error,
isEnabledByDefault: true);

public static readonly DiagnosticDescriptor ExtractWrapValueTypeReturnUnderReturnNull = new(
id: "FM0074",
title: "[ExtractProperty]/[WrapProperty] value-type return under NullHandling.ReturnNull collapses null source to default",
messageFormat: "[ExtractProperty]/[WrapProperty] method '{0}' returns value type '{1}' under NullHandling.ReturnNull; null sources will collapse to default({1}). Audit and consider NullHandling.ThrowException or a nullable return type if the distinction matters.",
category: Category,
defaultSeverity: DiagnosticSeverity.Info,
isEnabledByDefault: false);

public static readonly DiagnosticDescriptor ConditionAndSkipWhenBothSet = new(
id: "FM0060",
title: "Condition and SkipWhen are mutually exclusive",
Expand Down
44 changes: 44 additions & 0 deletions src/ForgeMap.Generator/ForgeCodeEmitter.AttributeDetection.cs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,50 @@ private bool HasConvertWithAttribute(IMethodSymbol method)
SymbolEqualityComparer.Default.Equals(a.AttributeClass, _convertWithAttributeSymbol));
}

private bool HasExtractPropertyAttribute(IMethodSymbol method)
{
if (_extractPropertyAttributeSymbol == null)
return false;

return method.GetAttributes().Any(a =>
SymbolEqualityComparer.Default.Equals(a.AttributeClass, _extractPropertyAttributeSymbol));
}

private bool HasWrapPropertyAttribute(IMethodSymbol method)
{
if (_wrapPropertyAttributeSymbol == null)
return false;

return method.GetAttributes().Any(a =>
SymbolEqualityComparer.Default.Equals(a.AttributeClass, _wrapPropertyAttributeSymbol));
}

/// <summary>
/// Returns the property-name argument from the [ExtractProperty] attribute on this method,
/// or null if the attribute is absent or malformed.
/// </summary>
private string? GetExtractPropertyName(IMethodSymbol method)
{
if (_extractPropertyAttributeSymbol == null) return null;
var attr = method.GetAttributes().FirstOrDefault(a =>
SymbolEqualityComparer.Default.Equals(a.AttributeClass, _extractPropertyAttributeSymbol));
if (attr == null || attr.ConstructorArguments.Length == 0) return null;
return attr.ConstructorArguments[0].Value as string;
}

/// <summary>
/// Returns the property-name argument from the [WrapProperty] attribute on this method,
/// or null if the attribute is absent or malformed.
/// </summary>
private string? GetWrapPropertyName(IMethodSymbol method)
{
if (_wrapPropertyAttributeSymbol == null) return null;
var attr = method.GetAttributes().FirstOrDefault(a =>
SymbolEqualityComparer.Default.Equals(a.AttributeClass, _wrapPropertyAttributeSymbol));
if (attr == null || attr.ConstructorArguments.Length == 0) return null;
return attr.ConstructorArguments[0].Value as string;
}

private bool HasUseExistingValueAttribute(IParameterSymbol param)
{
if (_useExistingValueAttributeSymbol == null)
Expand Down
Loading
Loading