diff --git a/docs/SPEC-v1.7-projection-and-conditional.md b/docs/SPEC-v1.7-projection-and-conditional.md
index b8289cf..252f13d 100644
--- a/docs/SPEC-v1.7-projection-and-conditional.md
+++ b/docs/SPEC-v1.7-projection-and-conditional.md
@@ -7,7 +7,7 @@ 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 |
-| 2 | Conditional property assignment (`Condition`, `SkipWhen`) | [#126](https://github.com/superyyrrzz/ForgeMap/issues/126) | Medium | Planned |
+| 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 |
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.
diff --git a/docs/superpowers/plans/2026-04-21-v1.7-conditional-assignment.md b/docs/superpowers/plans/2026-04-21-v1.7-conditional-assignment.md
new file mode 100644
index 0000000..2684c9f
--- /dev/null
+++ b/docs/superpowers/plans/2026-04-21-v1.7-conditional-assignment.md
@@ -0,0 +1,1424 @@
+# v1.7 Feature 2 — Conditional Property Assignment Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Add `Condition` and `SkipWhen` named arguments on `[ForgeProperty]` so users can guard per-property assignments with a predicate, primarily for `ForgeInto` update semantics.
+
+**Architecture:** Two new `string?` properties on `ForgePropertyAttribute` carry a forger-method name. The generator resolves the predicate, validates exclusivity (FM0060), signature (FM0061), target shape — not on `init`/`required`/ctor params (FM0062) — and conflict with `[ForgeFrom]`/`[ForgeWith]` (FM0063), then wraps the per-property assignment expression in an `if` guard. The guard composes with `ConvertWith` and `SelectProperty`: cache the source value to a local, run the predicate against it, then run the converter/projection in the assigned branch.
+
+**Tech Stack:** C# Roslyn incremental source generator (`ForgeMap.Generator`), public attributes in `ForgeMap.Abstractions`, xUnit tests in `ForgeMap.Tests`.
+
+---
+
+## Background — what already exists (read this before starting)
+
+- `ForgePropertyAttribute` is in `src/ForgeMap.Abstractions/ForgePropertyAttribute.cs`. Add new properties at the end, keep nullable defaults.
+- Per-property attribute config is parsed in `src/ForgeMap.Generator/ForgeCodeEmitter.Configuration.cs`. Each per-property feature has its own getter (`GetSelectPropertyMappings`, `GetPropertyConvertWithMappings`, etc.). Follow the same shape: a `Get…Mappings(IMethodSymbol)` method that walks `_forgePropertyAttributeSymbol` attributes via `GetMethodAttributes`.
+- `ResolvedMethodConfig` (in `src/ForgeMap.Generator/ForgerConfig.cs`) holds all parsed per-property mappings. Add new fields and constructor parameters in the same style.
+- `ResolveMethodConfig` (in `Configuration.cs`) calls each `Get…Mappings` and packages the result. `ResolveInheritedConfig` merges base-class config from `[IncludeBaseForge]`. Both must be updated for any new mapping kind.
+- The forge-method writer is `GenerateForgeMethod` in `src/ForgeMap.Generator/ForgeCodeEmitter.ForgeMethod.cs`. It calls `GeneratePropertyAssignment` (in `ForgeCodeEmitter.PropertyAssignment.cs`) to produce a single assignment expression per destination property.
+- The forge-into writer is `GenerateForgeIntoMethod` in `src/ForgeMap.Generator/ForgeCodeEmitter.ForgeIntoMethod.cs`. It writes statements directly with `sb.AppendLine` rather than collecting expressions — this is the **primary use case** for conditional assignment.
+- `ResolveConstructor` in `ForgeMethod.cs` decides which destination properties are filled via constructor parameters (`ctorCoveredDestProps` in `GenerateCtorPlusInitializer`). Conditional cannot apply to ctor-bound properties — FM0062 must trigger here.
+- Object-initializer destinations include `init` setters (see `IsInitOnly` checks in `PropertyAssignment.cs`). Conditional cannot wrap an `init` assignment with an `if` either — FM0062 must also fire for those.
+- Diagnostics live in `src/ForgeMap.Generator/DiagnosticDescriptors.cs`. Categories use the constant `Category = "ForgeMap"`. Add new descriptors at the end.
+- Analyzer release tracking lives in `src/ForgeMap.Generator/AnalyzerReleases.Unshipped.md`. Every new diagnostic ID must be appended there or RS2000 fails the build.
+- Tests use the helper `RunGenerator` from `tests/ForgeMap.Tests/TestHelper.cs`. Each feature gets its own `*GeneratorTests.cs` file (see `SelectPropertyGeneratorTests.cs` for the closest model).
+- Build: `dotnet build` from repo root. Test a single class: `dotnet test --filter "FullyQualifiedName~ConditionalAssignmentGeneratorTests"`.
+- Spec: `docs/SPEC-v1.7-projection-and-conditional.md` Feature 2.
+
+### Key generator design rules from the spec to internalize
+
+1. **Spec rule (lines 340–342):** Do NOT synthesize an extra `!= null` guard around the user predicate. The predicate IS the contract. The generator caches the source property to a local, calls the predicate on it, then if the predicate passes, accesses the cached local **without `?.`** (and with `!` on the local when the static type was nullable but the converter/projection wants non-null). If the predicate doesn't actually guard against null, the user gets a runtime NRE — this is intentional.
+
+2. **`Condition` receives the source-property value, `SkipWhen` receives the source object.** Both return `bool`. `Condition`-true means assign; `SkipWhen`-true means skip.
+
+3. **Mutual exclusivity:** `Condition` xor `SkipWhen` per `[ForgeProperty]` (FM0060). Cannot combine with `[ForgeFrom]` / `[ForgeWith]` on the same destination property (FM0063). CAN combine with `ConvertWith`/`ConvertWithType` and `SelectProperty` (guard wraps converter/projection).
+
+4. **Diagnostic precedence with FM0072:** When `SelectProperty` is set on a property AND `[ForgeFrom]`/`[ForgeWith]` targets the same property AND `Condition`/`SkipWhen` is also set, only **FM0072** fires; **FM0063** is suppressed for that property. (Spec §"Resolution Algorithm" step 5.)
+
+5. **Predicate methods may be static or instance, any accessibility, on the forger class.** The single parameter type must be `bool`-returning and accept `T` where `T` is assignable from the source-property type (`Condition`) or source-object type (`SkipWhen`). The generator emits a bare call `PredName(arg)` — no `this.` qualifier needed since the partial method is on the forger.
+
+6. **Generated code shape (no converter):**
+ ```csharp
+ if (IsNotNull(source.Protocol))
+ destination.Protocol = source.Protocol;
+ ```
+
+7. **Generated code shape (with `ConvertWith`):** When the source value's static type is nullable AND the converter parameter is non-nullable, append `!`:
+ ```csharp
+ if (IsNotNull(source.CreatedAt))
+ destination.CreatedAt = ToUtcDateTime(source.CreatedAt!);
+ ```
+
+8. **Generated code shape (with `SelectProperty`):** Cache once, drop `?.`:
+ ```csharp
+ var __lines = source.Lines;
+ if (HasItems(__lines))
+ destination.ProductNames = __lines!.Select(__x => __x.ProductName).ToList();
+ ```
+
+9. **Forge (new-instance) method shape:** Skipped assignment leaves the destination property at its default-constructed value. Spec example (lines 348–358):
+ ```csharp
+ var __result = new SettingsDto(); // Protocol = "default-protocol"
+ if (IsNotNull(source.Protocol))
+ __result.Protocol = source.Protocol;
+ return __result;
+ ```
+ This means in `Forge` mode the assignment cannot live inside the object initializer — it must be a post-construction `result.Prop = …` statement guarded by `if`. The existing `skipNullAssignments` machinery in `GenerateSimpleObjectInit` / `GenerateCtorPlusInitializer` / `GenerateObjInitWithAfterForge` is the lever to reuse: a guarded property is emitted as a `(DestPropName, SourceExpr, LocalVarName, AssignExpr)` tuple, the writer already moves these out of the initializer block. Just feed conditional assignments through it.
+
+10. **`ForgeInto` shape:** `GenerateForgeIntoMethod` writes statements directly via `sb.AppendLine(...)`. The conditional code path is added inline in `GenerateForgeIntoPropertyAssignment` (which returns `void`, not a string).
+
+11. **`init`/`required`/ctor-param targets:** FM0062 (error). The check must run BEFORE the assignment is emitted so we don't produce broken code. Detect:
+ - Constructor-bound: the destination property is in `ctorCoveredDestProps` (computed in `GenerateForgeMethod`).
+ - `init`-only: `destProp.SetMethod?.IsInitOnly == true`.
+ - `required`: `destProp.IsRequired == true`.
+
+12. **`[Ignore]` wins:** if a property is in `ignoredProperties`, conditional config is silently dropped (no diagnostic). The existing `if (ignoredProperties.Contains(destProp.Name)) return null;` early-out at the top of `GeneratePropertyAssignment` and the equivalent check in `GenerateForgeIntoPropertyAssignment` already handle this.
+
+13. **`[AfterForge]` runs after guarded assignments** — no special handling needed; it operates on `result` after the initializer and post-construction blocks.
+
+14. **`[ReverseForge]` is NOT auto-reversed** — predicate semantics rarely have inverses. The new attribute fields simply don't propagate to the reverse method generator. No changes needed in `ForgeCodeEmitter.ReverseMethod.cs` because the reverse generator builds its own `[ForgeProperty]` list per-direction.
+
+15. **Inheritance via `[IncludeBaseForge]`:** merge using first-wins (same as other per-property kinds). Add `conditionMappings` and `skipWhenMappings` to `ResolveInheritedConfig`'s parameter list and merge loops.
+
+---
+
+## File Structure
+
+**Modified files:**
+- `src/ForgeMap.Abstractions/ForgePropertyAttribute.cs` — add `Condition` and `SkipWhen` properties.
+- `src/ForgeMap.Generator/DiagnosticDescriptors.cs` — add FM0060–FM0064 descriptors.
+- `src/ForgeMap.Generator/AnalyzerReleases.Unshipped.md` — register new diagnostic IDs.
+- `src/ForgeMap.Generator/ForgerConfig.cs` — add `ConditionMappings` and `SkipWhenMappings` to `ResolvedMethodConfig`.
+- `src/ForgeMap.Generator/ForgeCodeEmitter.Configuration.cs` — add `GetConditionMappings` and `GetSkipWhenMappings`; update `ResolveMethodConfig` and `ResolveInheritedConfig`.
+- `src/ForgeMap.Generator/ForgeCodeEmitter.ForgeMethod.cs` — emit FM0062 for ctor-bound/init/required targets; pass conditional maps into the per-property writers.
+- `src/ForgeMap.Generator/ForgeCodeEmitter.ForgeIntoMethod.cs` — emit guarded assignments inline; add FM0063/FM0072 precedence handling for `ForgeInto` properties.
+- `src/ForgeMap.Generator/ForgeCodeEmitter.PropertyAssignment.cs` — wire conditional through the existing `skipNullAssignments` path so guarded assignments are emitted post-construction in `Forge` methods.
+
+**Created files:**
+- `src/ForgeMap.Generator/ForgeCodeEmitter.Conditional.cs` — new partial-class file housing `ResolvePredicateMethod`, `BuildConditionalGuardExpression`, and the helper that decides whether a destination property is conditional-eligible. Keeps the conditional code together (matches the pattern used by `ForgeCodeEmitter.Projection.cs`).
+- `tests/ForgeMap.Tests/ConditionalAssignmentGeneratorTests.cs` — full test coverage.
+
+---
+
+## Task 1: Add `Condition` and `SkipWhen` properties to `ForgePropertyAttribute`
+
+**Files:**
+- Modify: `src/ForgeMap.Abstractions/ForgePropertyAttribute.cs`
+- Test: `tests/ForgeMap.Tests/ConditionalAssignmentGeneratorTests.cs` (created in Task 4 — for now, just smoke-test the property exists by compiling the build)
+
+- [ ] **Step 1: Add the two properties to the attribute**
+
+Open `src/ForgeMap.Abstractions/ForgePropertyAttribute.cs`. After the existing `SelectProperty` declaration (currently the last property), append:
+
+```csharp
+ ///
+ /// Name of a predicate method on the forger class. Called with the source property value;
+ /// when it returns false, the destination assignment is skipped.
+ /// Signature: bool MethodName(TSourceProperty value). Static or instance, any accessibility.
+ /// Mutually exclusive with on the same [ForgeProperty].
+ /// Cannot be combined with [ForgeFrom] or [ForgeWith] on the same destination property.
+ /// Not supported on properties bound to a constructor parameter, an init setter, or a required member.
+ ///
+ public string? Condition { get; set; }
+
+ ///
+ /// Name of a predicate method on the forger class. Called with the source object;
+ /// when it returns true, the destination assignment is skipped.
+ /// Signature: bool MethodName(TSource source). Static or instance, any accessibility.
+ /// Mutually exclusive with on the same [ForgeProperty].
+ /// Cannot be combined with [ForgeFrom] or [ForgeWith] on the same destination property.
+ /// Not supported on properties bound to a constructor parameter, an init setter, or a required member.
+ ///
+ public string? SkipWhen { get; set; }
+```
+
+- [ ] **Step 2: Build to confirm the attribute compiles**
+
+Run: `dotnet build src/ForgeMap.Abstractions/ForgeMap.Abstractions.csproj`
+Expected: Build succeeded, 0 errors.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add src/ForgeMap.Abstractions/ForgePropertyAttribute.cs
+git commit -m "feat(v1.7): add Condition and SkipWhen properties to [ForgeProperty]"
+```
+
+---
+
+## Task 2: Register diagnostic descriptors FM0060–FM0064
+
+**Files:**
+- Modify: `src/ForgeMap.Generator/DiagnosticDescriptors.cs`
+- Modify: `src/ForgeMap.Generator/AnalyzerReleases.Unshipped.md`
+
+- [ ] **Step 1: Append the five descriptors to DiagnosticDescriptors.cs**
+
+Open `src/ForgeMap.Generator/DiagnosticDescriptors.cs`. After the last existing descriptor (`SelectPropertyNotSupportedOnForgeInto`, FM0075), append:
+
+```csharp
+ public static readonly DiagnosticDescriptor ConditionAndSkipWhenBothSet = new(
+ id: "FM0060",
+ title: "Condition and SkipWhen are mutually exclusive",
+ messageFormat: "Property '{0}' has both Condition and SkipWhen set on the same [ForgeProperty] — choose one",
+ category: Category,
+ defaultSeverity: DiagnosticSeverity.Error,
+ isEnabledByDefault: true);
+
+ public static readonly DiagnosticDescriptor ConditionalPredicateInvalid = new(
+ id: "FM0061",
+ title: "Conditional predicate method not found or has wrong signature",
+ messageFormat: "Predicate method '{0}' for property '{1}' was not found on the forger class or has the wrong signature; expected: bool {0}({2})",
+ category: Category,
+ defaultSeverity: DiagnosticSeverity.Error,
+ isEnabledByDefault: true);
+
+ public static readonly DiagnosticDescriptor ConditionalNotSupportedOnInitOrCtor = new(
+ id: "FM0062",
+ title: "Condition/SkipWhen cannot be applied to init, required, or constructor-bound properties",
+ messageFormat: "Condition/SkipWhen cannot be applied to property '{0}' because it is set via constructor or init/required. Use [ForgeFrom] to choose between alternative constructions, or drop init if you need conditional post-construction assignment",
+ category: Category,
+ defaultSeverity: DiagnosticSeverity.Error,
+ isEnabledByDefault: true);
+
+ public static readonly DiagnosticDescriptor ConditionalConflictsWithForgeFromOrWith = new(
+ id: "FM0063",
+ title: "Condition/SkipWhen conflicts with [ForgeFrom] / [ForgeWith]",
+ messageFormat: "Property '{0}' has Condition/SkipWhen and is also targeted by [ForgeFrom] or [ForgeWith] — choose one",
+ category: Category,
+ defaultSeverity: DiagnosticSeverity.Error,
+ isEnabledByDefault: true);
+
+ public static readonly DiagnosticDescriptor ConditionalAssignmentApplied = new(
+ id: "FM0064",
+ title: "Conditional assignment applied for property",
+ messageFormat: "Conditional assignment applied for property '{0}' via '{1}'",
+ category: Category,
+ defaultSeverity: DiagnosticSeverity.Info,
+ isEnabledByDefault: false);
+```
+
+- [ ] **Step 2: Append the five rules to AnalyzerReleases.Unshipped.md**
+
+Open `src/ForgeMap.Generator/AnalyzerReleases.Unshipped.md`. After the last row (`FM0075`), append (keep the table column alignment loose — markdown is fine with single spaces):
+
+```
+FM0060 | ForgeMap | Error | ConditionAndSkipWhenBothSet
+FM0061 | ForgeMap | Error | ConditionalPredicateInvalid
+FM0062 | ForgeMap | Error | ConditionalNotSupportedOnInitOrCtor
+FM0063 | ForgeMap | Error | ConditionalConflictsWithForgeFromOrWith
+FM0064 | ForgeMap | Disabled | ConditionalAssignmentApplied
+```
+
+- [ ] **Step 3: Build to confirm RS2000 (analyzer-release tracking) is satisfied**
+
+Run: `dotnet build src/ForgeMap.Generator/ForgeMap.Generator.csproj`
+Expected: Build succeeded, 0 errors. (If RS2000 fails, the diagnostic IDs in the source don't match the release file — re-check spelling.)
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add src/ForgeMap.Generator/DiagnosticDescriptors.cs src/ForgeMap.Generator/AnalyzerReleases.Unshipped.md
+git commit -m "feat(v1.7): register FM0060-FM0064 diagnostics for Condition/SkipWhen"
+```
+
+---
+
+## Task 3: Parse `Condition` and `SkipWhen` from `[ForgeProperty]` and thread through `ResolvedMethodConfig`
+
+**Files:**
+- Modify: `src/ForgeMap.Generator/ForgerConfig.cs`
+- Modify: `src/ForgeMap.Generator/ForgeCodeEmitter.Configuration.cs`
+
+- [ ] **Step 1: Extend `ResolvedMethodConfig` with two new dictionaries**
+
+Open `src/ForgeMap.Generator/ForgerConfig.cs`. Modify the `ResolvedMethodConfig` constructor and properties.
+
+Add these two parameters at the end of the constructor signature (after `selectPropertyMappings`):
+
+```csharp
+ Dictionary? conditionMappings = null,
+ Dictionary? skipWhenMappings = null)
+```
+
+Inside the constructor body, after the `SelectPropertyMappings = …;` line, add:
+
+```csharp
+ ConditionMappings = conditionMappings ?? new Dictionary(StringComparer.Ordinal);
+ SkipWhenMappings = skipWhenMappings ?? new Dictionary(StringComparer.Ordinal);
+```
+
+After the `SelectPropertyMappings` property, add:
+
+```csharp
+ /// Per-property Condition (v1.7) predicate mappings. Key = dest property name, Value = predicate method name (called with source-property value).
+ public Dictionary ConditionMappings { get; }
+ /// Per-property SkipWhen (v1.7) predicate mappings. Key = dest property name, Value = predicate method name (called with source object).
+ public Dictionary SkipWhenMappings { get; }
+```
+
+- [ ] **Step 2: Add `GetConditionMappings` and `GetSkipWhenMappings` getters**
+
+Open `src/ForgeMap.Generator/ForgeCodeEmitter.Configuration.cs`. After the existing `GetSelectPropertyMappings` method (currently ends around line 223), insert:
+
+```csharp
+ ///
+ /// Gets per-property Condition mappings from [ForgeProperty(Condition=...)] attributes.
+ /// Returns a dictionary mapping destination property name to the predicate method name.
+ /// Predicate is invoked with the source property value.
+ ///
+ private Dictionary GetConditionMappings(IMethodSymbol method)
+ {
+ var result = new Dictionary(StringComparer.Ordinal);
+ foreach (var attr in GetMethodAttributes(method, _forgePropertyAttributeSymbol))
+ {
+ if (attr.ConstructorArguments.Length < 2)
+ continue;
+ var destinationProperty = attr.ConstructorArguments[1].Value as string;
+ if (string.IsNullOrEmpty(destinationProperty))
+ continue;
+
+ foreach (var named in attr.NamedArguments)
+ {
+ if (named.Key == "Condition" && named.Value.Value is string predicateName && !string.IsNullOrEmpty(predicateName))
+ {
+ result[destinationProperty!] = predicateName;
+ }
+ }
+ }
+ return result;
+ }
+
+ ///
+ /// Gets per-property SkipWhen mappings from [ForgeProperty(SkipWhen=...)] attributes.
+ /// Returns a dictionary mapping destination property name to the predicate method name.
+ /// Predicate is invoked with the source object.
+ ///
+ private Dictionary GetSkipWhenMappings(IMethodSymbol method)
+ {
+ var result = new Dictionary(StringComparer.Ordinal);
+ foreach (var attr in GetMethodAttributes(method, _forgePropertyAttributeSymbol))
+ {
+ if (attr.ConstructorArguments.Length < 2)
+ continue;
+ var destinationProperty = attr.ConstructorArguments[1].Value as string;
+ if (string.IsNullOrEmpty(destinationProperty))
+ continue;
+
+ foreach (var named in attr.NamedArguments)
+ {
+ if (named.Key == "SkipWhen" && named.Value.Value is string predicateName && !string.IsNullOrEmpty(predicateName))
+ {
+ result[destinationProperty!] = predicateName;
+ }
+ }
+ }
+ return result;
+ }
+```
+
+- [ ] **Step 3: Wire the new getters into `ResolveMethodConfig`**
+
+In `ForgeCodeEmitter.Configuration.cs`, find `ResolveMethodConfig` (currently starts around line 589). After the line `var selectPropertyMappings = GetSelectPropertyMappings(method);`, add:
+
+```csharp
+ var conditionMappings = GetConditionMappings(method);
+ var skipWhenMappings = GetSkipWhenMappings(method);
+```
+
+In the same method, change the `ResolveInheritedConfig(...)` call: append `conditionMappings, skipWhenMappings,` immediately before the final `new HashSet()` argument. (Step 4 below changes the `ResolveInheritedConfig` signature to accept these — keep the calls in sync.)
+
+Then change the `return new ResolvedMethodConfig(...)` to add the two new arguments at the very end:
+
+```csharp
+ return new ResolvedMethodConfig(
+ ignoredProperties,
+ propertyMappings,
+ resolverMappings,
+ forgeWithMappings,
+ beforeForgeHooks,
+ afterForgeHooks,
+ nullPropertyHandlingOverrides,
+ existingTargetProperties,
+ propertyConvertWithMappings,
+ selectPropertyMappings,
+ conditionMappings,
+ skipWhenMappings);
+```
+
+- [ ] **Step 4: Wire the new getters into `ResolveInheritedConfig` (first-wins merge)**
+
+In `ForgeCodeEmitter.Configuration.cs`, find `ResolveInheritedConfig` (currently starts around line 379). Add two parameters at the end of the parameter list, immediately before `HashSet visited`:
+
+```csharp
+ Dictionary conditionMappings,
+ Dictionary skipWhenMappings,
+```
+
+Inside the method, locate the loop that calls `Get…Mappings` on `baseMethod`. After `var baseSelectPropertyMappings = GetSelectPropertyMappings(baseMethod);`, add:
+
+```csharp
+ var baseConditionMappings = GetConditionMappings(baseMethod);
+ var baseSkipWhenMappings = GetSkipWhenMappings(baseMethod);
+```
+
+Update the recursive `ResolveInheritedConfig(...)` call inside the loop to pass `baseConditionMappings, baseSkipWhenMappings,` before `visited`.
+
+After the existing `// Merge base SelectProperty mappings using first-wins semantics.` block, append two more first-wins merge blocks:
+
+```csharp
+ // Merge base Condition mappings using first-wins semantics
+ foreach (var kvp in baseConditionMappings)
+ {
+ if (!conditionMappings.ContainsKey(kvp.Key))
+ conditionMappings[kvp.Key] = kvp.Value;
+ }
+
+ // Merge base SkipWhen mappings using first-wins semantics
+ foreach (var kvp in baseSkipWhenMappings)
+ {
+ if (!skipWhenMappings.ContainsKey(kvp.Key))
+ skipWhenMappings[kvp.Key] = kvp.Value;
+ }
+```
+
+- [ ] **Step 5: Build to confirm the threading compiles**
+
+Run: `dotnet build src/ForgeMap.Generator/ForgeMap.Generator.csproj`
+Expected: Build succeeded, 0 errors. (Conditional config is parsed but not yet emitted — call sites in `ForgeMethod.cs` / `ForgeIntoMethod.cs` will pick it up via `cfg.ConditionMappings` / `cfg.SkipWhenMappings` in later tasks.)
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add src/ForgeMap.Generator/ForgerConfig.cs src/ForgeMap.Generator/ForgeCodeEmitter.Configuration.cs
+git commit -m "feat(v1.7): parse Condition/SkipWhen from [ForgeProperty] into ResolvedMethodConfig"
+```
+
+---
+
+## Task 4: Create the conditional helper module with predicate resolution and guard building
+
+**Files:**
+- Create: `src/ForgeMap.Generator/ForgeCodeEmitter.Conditional.cs`
+
+This task adds the central helper used by both `Forge` and `ForgeInto` paths. It does NOT yet wire any call sites — those come in Tasks 5 and 6.
+
+- [ ] **Step 1: Create the file with the helper module**
+
+Create `src/ForgeMap.Generator/ForgeCodeEmitter.Conditional.cs` with the following contents:
+
+```csharp
+using System.Collections.Generic;
+using System.Linq;
+using Microsoft.CodeAnalysis;
+using static ForgeMap.Generator.TypeAnalysisHelper;
+
+namespace ForgeMap.Generator;
+
+internal sealed partial class ForgeCodeEmitter
+{
+ ///
+ /// Conditional mode — Condition or SkipWhen.
+ ///
+ internal enum ConditionalKind
+ {
+ Condition, // predicate true => assign
+ SkipWhen, // predicate true => skip
+ }
+
+ ///
+ /// Result of resolving a per-property predicate. Indicates whether a guard
+ /// applies, and if so the predicate identity needed to emit the guard expression.
+ ///
+ internal readonly struct ConditionalResolution
+ {
+ private ConditionalResolution(bool applicable, bool failed, ConditionalKind kind, IMethodSymbol? predicate, string? predicateName)
+ {
+ Applicable = applicable;
+ DidFail = failed;
+ Kind = kind;
+ Predicate = predicate;
+ PredicateName = predicateName;
+ }
+
+ public bool Applicable { get; }
+ public bool DidFail { get; }
+ public ConditionalKind Kind { get; }
+ public IMethodSymbol? Predicate { get; }
+ public string? PredicateName { get; }
+
+ public static ConditionalResolution NotApplicable() => new(false, false, default, null, null);
+ public static ConditionalResolution Failed() => new(true, true, default, null, null);
+ public static ConditionalResolution FromPredicate(ConditionalKind kind, IMethodSymbol method, string name)
+ => new(true, false, kind, method, name);
+ }
+
+ ///
+ /// Resolves the per-property conditional configuration for a destination property:
+ /// validates exclusivity (FM0060), conflict with [ForgeFrom]/[ForgeWith] (FM0063
+ /// — suppressed when SelectProperty conflict already triggered FM0072), the destination
+ /// shape (FM0062 for ctor/init/required), and the predicate signature (FM0061).
+ /// Reports diagnostics directly. Returns NotApplicable when no Condition/SkipWhen
+ /// is set; Failed when a diagnostic was emitted; FromPredicate with the resolved
+ /// predicate method when emit may proceed.
+ ///
+ /// IMPORTANT: When this returns Failed, the caller should fall back to the *unguarded*
+ /// per-property assignment so the partial method still produces compilable output.
+ ///
+ private ConditionalResolution ResolveConditionalForProperty(
+ IPropertySymbol destProp,
+ ITypeSymbol sourceType,
+ Dictionary conditionMappings,
+ Dictionary skipWhenMappings,
+ Dictionary propertyMappings,
+ Dictionary selectPropertyMappings,
+ Dictionary resolverMappings,
+ Dictionary forgeWithMappings,
+ bool isCtorBound,
+ ForgerInfo forger,
+ SourceProductionContext context,
+ IMethodSymbol method)
+ {
+ var hasCondition = conditionMappings.TryGetValue(destProp.Name, out var conditionName);
+ var hasSkipWhen = skipWhenMappings.TryGetValue(destProp.Name, out var skipWhenName);
+ if (!hasCondition && !hasSkipWhen)
+ return ConditionalResolution.NotApplicable();
+
+ var location = method.Locations.FirstOrDefault();
+
+ // FM0060: mutual exclusivity
+ if (hasCondition && hasSkipWhen)
+ {
+ ReportDiagnosticIfNotSuppressed(context,
+ DiagnosticDescriptors.ConditionAndSkipWhenBothSet,
+ location, destProp.Name);
+ return ConditionalResolution.Failed();
+ }
+
+ // FM0063 (suppressed by FM0072 precedence): conflict with [ForgeFrom] / [ForgeWith]
+ var hasForgeFromOrWith = resolverMappings.ContainsKey(destProp.Name) || forgeWithMappings.ContainsKey(destProp.Name);
+ if (hasForgeFromOrWith)
+ {
+ // Per spec: when SelectProperty also conflicts with ForgeFrom/ForgeWith,
+ // FM0072 wins and FM0063 is suppressed for this destination property.
+ if (!selectPropertyMappings.ContainsKey(destProp.Name))
+ {
+ ReportDiagnosticIfNotSuppressed(context,
+ DiagnosticDescriptors.ConditionalConflictsWithForgeFromOrWith,
+ location, destProp.Name);
+ }
+ return ConditionalResolution.Failed();
+ }
+
+ // FM0062: destination must be a settable, non-init, non-required, non-ctor-bound property
+ var isInitOnly = destProp.SetMethod?.IsInitOnly == true;
+ var isRequired = destProp.IsRequired;
+ if (isCtorBound || isInitOnly || isRequired)
+ {
+ ReportDiagnosticIfNotSuppressed(context,
+ DiagnosticDescriptors.ConditionalNotSupportedOnInitOrCtor,
+ location, destProp.Name);
+ return ConditionalResolution.Failed();
+ }
+
+ // Resolve predicate method on the forger class (any accessibility, static or instance)
+ var predicateName = hasCondition ? conditionName! : skipWhenName!;
+ var kind = hasCondition ? ConditionalKind.Condition : ConditionalKind.SkipWhen;
+
+ // Determine the expected argument type
+ ITypeSymbol expectedArgType;
+ if (kind == ConditionalKind.Condition)
+ {
+ // Condition: predicate sees the source property value (post-path resolution)
+ string sourcePropPath = propertyMappings.TryGetValue(destProp.Name, out var mapped)
+ ? mapped
+ : destProp.Name;
+ expectedArgType = ResolvePathLeafType(sourcePropPath, (INamedTypeSymbol)sourceType) ?? sourceType;
+ }
+ else
+ {
+ expectedArgType = sourceType;
+ }
+
+ var candidates = forger.Symbol.GetMembers(predicateName).OfType().ToList();
+ var predicate = candidates.FirstOrDefault(m =>
+ m.Parameters.Length == 1 &&
+ m.ReturnType.SpecialType == SpecialType.System_Boolean &&
+ CanAssign(expectedArgType, m.Parameters[0].Type));
+
+ if (predicate == null)
+ {
+ ReportDiagnosticIfNotSuppressed(context,
+ DiagnosticDescriptors.ConditionalPredicateInvalid,
+ location, predicateName, destProp.Name, expectedArgType.ToDisplayString());
+ return ConditionalResolution.Failed();
+ }
+
+ // FM0064 — info diagnostic
+ ReportDiagnosticIfNotSuppressed(context,
+ DiagnosticDescriptors.ConditionalAssignmentApplied,
+ location, destProp.Name, predicateName);
+
+ return ConditionalResolution.FromPredicate(kind, predicate, predicateName);
+ }
+
+ ///
+ /// Builds the guard expression text used in if (<guard>) ...
+ /// for a resolved conditional. Caller supplies the cached source-value or
+ /// source-object expression appropriate to the predicate's .
+ /// SkipWhen is negated (predicate true => skip => emit when false).
+ ///
+ private static string BuildConditionalGuardExpression(in ConditionalResolution resolution, string argExpr)
+ {
+ var call = $"{resolution.PredicateName}({argExpr})";
+ return resolution.Kind == ConditionalKind.SkipWhen ? $"!{call}" : call;
+ }
+}
+```
+
+- [ ] **Step 2: Build to confirm the helper compiles**
+
+Run: `dotnet build src/ForgeMap.Generator/ForgeMap.Generator.csproj`
+Expected: Build succeeded, 0 errors.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add src/ForgeMap.Generator/ForgeCodeEmitter.Conditional.cs
+git commit -m "feat(v1.7): add ResolveConditionalForProperty helper for Condition/SkipWhen"
+```
+
+---
+
+## Task 5: Wire conditional guards into `Forge` (new-instance) methods via existing post-construction path
+
+**Files:**
+- Modify: `src/ForgeMap.Generator/ForgeCodeEmitter.PropertyAssignment.cs`
+- Modify: `src/ForgeMap.Generator/ForgeCodeEmitter.ForgeMethod.cs`
+
+The `Forge` method always runs the destination through an object initializer. A conditional assignment cannot live inside an initializer because the guard requires an `if`. The existing `skipNullAssignments` machinery already does exactly this for `NullPropertyHandling.SkipNull`: the writer collects `(DestPropName, SourceExpr, LocalVarName, AssignExpr)` tuples and emits a guarded `result.Prop = …;` after construction. We piggyback on it.
+
+- [ ] **Step 1: Pass conditional maps through `GeneratePropertyAssignment`**
+
+Open `src/ForgeMap.Generator/ForgeCodeEmitter.PropertyAssignment.cs`. Modify the signature of `GeneratePropertyAssignment` to add two more optional parameters at the very end:
+
+```csharp
+ Dictionary? selectPropertyMappings = null,
+ Dictionary? conditionMappings = null,
+ Dictionary? skipWhenMappings = null,
+ bool isCtorBound = false,
+ ForgerInfo? conditionalForger = null)
+```
+
+(Existing trailing `selectPropertyMappings = null` parameter stays where it is — append after it.)
+
+- [ ] **Step 2: Apply the conditional check at the top of `GeneratePropertyAssignment`**
+
+Inside `GeneratePropertyAssignment`, immediately after the existing early-out:
+
+```csharp
+ if (ignoredProperties.Contains(destProp.Name))
+ return null;
+```
+
+insert:
+
+```csharp
+ // v1.7 Conditional (Condition / SkipWhen) — validates and reports diagnostics.
+ // When applicable and not failed, the assignment is routed through the
+ // skipNullAssignments machinery so it is emitted as a post-construction
+ // guarded statement (object initializers cannot contain `if`).
+ ConditionalResolution conditionalResolution = ConditionalResolution.NotApplicable();
+ if (conditionalForger != null
+ && (conditionMappings != null && conditionMappings.ContainsKey(destProp.Name)
+ || skipWhenMappings != null && skipWhenMappings.ContainsKey(destProp.Name)))
+ {
+ conditionalResolution = ResolveConditionalForProperty(
+ destProp, sourceType,
+ conditionMappings ?? new Dictionary(System.StringComparer.Ordinal),
+ skipWhenMappings ?? new Dictionary(System.StringComparer.Ordinal),
+ propertyMappings,
+ selectPropertyMappings ?? new Dictionary(System.StringComparer.Ordinal),
+ resolverMappings, forgeWithMappings,
+ isCtorBound,
+ conditionalForger, context, method);
+ // On Failed, fall through to the normal (unguarded) assignment so the
+ // partial method still produces compilable output. The diagnostic has
+ // already been reported.
+ }
+```
+
+Then, immediately before the existing `return $"{sourceExpr}{nullForgiving}";` at the bottom of the `[ForgeProperty]` mapping branch (currently around line 222), and at every other final return in `GeneratePropertyAssignment` that produces a non-null assignment expression, wrap the result in a conditional rewriter helper. To keep the change small and centralized, instead replace the bottom of the method's various `return …;` paths with a single late-stage rewrite. The cleanest way is:
+
+ 1. Refactor `GeneratePropertyAssignment` so that every branch that would have returned a non-null `expression` first stores it in a local `string? assignExpr = …;`, then `goto Finish;` or falls through.
+ 2. At the `Finish:` label, if `conditionalResolution.Applicable && !conditionalResolution.DidFail && skipNullAssignments != null && assignExpr != null`, emit a skipNull entry and return null instead of returning the expression.
+
+Because the existing method has many returns, the pragmatic and reviewable approach is to keep the existing returns intact and instead add a helper that the *caller* uses to post-process the result. That keeps `PropertyAssignment.cs` mechanically simple.
+
+**Refined approach for Step 2 (do this instead of the goto-rewrite):** keep the conditional check at the top as written above, but DO NOT consume it inside `GeneratePropertyAssignment`. Instead, add a `out` parameter so the caller can post-process:
+
+Replace the snippet you just inserted with:
+
+```csharp
+ // v1.7 Conditional (Condition / SkipWhen) — validate and report diagnostics
+ // before the assignment is built. The actual guard is applied by the caller
+ // via PostProcessConditionalAssignment because object initializers cannot
+ // contain `if` and the writer already moves multi-statement assignments
+ // out of the initializer via skipNullAssignments.
+ if (conditionalForger != null
+ && conditionMappings != null && conditionMappings.ContainsKey(destProp.Name))
+ {
+ ResolveConditionalForProperty(
+ destProp, sourceType,
+ conditionMappings,
+ skipWhenMappings ?? new Dictionary(System.StringComparer.Ordinal),
+ propertyMappings,
+ selectPropertyMappings ?? new Dictionary(System.StringComparer.Ordinal),
+ resolverMappings, forgeWithMappings,
+ isCtorBound,
+ conditionalForger, context, method);
+ }
+ else if (conditionalForger != null
+ && skipWhenMappings != null && skipWhenMappings.ContainsKey(destProp.Name))
+ {
+ ResolveConditionalForProperty(
+ destProp, sourceType,
+ conditionMappings ?? new Dictionary(System.StringComparer.Ordinal),
+ skipWhenMappings,
+ propertyMappings,
+ selectPropertyMappings ?? new Dictionary(System.StringComparer.Ordinal),
+ resolverMappings, forgeWithMappings,
+ isCtorBound,
+ conditionalForger, context, method);
+ }
+```
+
+Note: this only reports diagnostics; it does not affect the returned assignment expression. The caller in `ForgeMethod.cs` is responsible for the post-processing. (The reason the diagnostic call lives here rather than in the caller is that the existing `[Ignore]` early-out and resolver/forge-with branches each interact with conditional differently; centralizing the resolution avoids drift.)
+
+The diagnostic side effect is intentional. The caller still needs the resolution result to do the wrapping — for that we add a small re-resolve helper next.
+
+- [ ] **Step 3: Add a re-resolve helper that returns the resolution without re-emitting diagnostics**
+
+Open `src/ForgeMap.Generator/ForgeCodeEmitter.Conditional.cs`. Append a second helper inside the partial class:
+
+```csharp
+ ///
+ /// Re-resolves the conditional predicate for a destination property *without*
+ /// reporting diagnostics. Used by the Forge writer after GeneratePropertyAssignment
+ /// has already validated the configuration and reported any errors. Returns
+ /// NotApplicable when no Condition/SkipWhen is set or when validation would fail.
+ ///
+ private ConditionalResolution TryResolveConditionalSilently(
+ IPropertySymbol destProp,
+ ITypeSymbol sourceType,
+ Dictionary conditionMappings,
+ Dictionary skipWhenMappings,
+ Dictionary propertyMappings,
+ Dictionary selectPropertyMappings,
+ Dictionary resolverMappings,
+ Dictionary forgeWithMappings,
+ bool isCtorBound,
+ ForgerInfo forger)
+ {
+ var hasCondition = conditionMappings.TryGetValue(destProp.Name, out var conditionName);
+ var hasSkipWhen = skipWhenMappings.TryGetValue(destProp.Name, out var skipWhenName);
+ if (!hasCondition && !hasSkipWhen) return ConditionalResolution.NotApplicable();
+ if (hasCondition && hasSkipWhen) return ConditionalResolution.NotApplicable(); // FM0060 — silent fallback
+ if (resolverMappings.ContainsKey(destProp.Name) || forgeWithMappings.ContainsKey(destProp.Name))
+ return ConditionalResolution.NotApplicable(); // FM0063/FM0072 — silent fallback
+ if (isCtorBound || destProp.SetMethod?.IsInitOnly == true || destProp.IsRequired)
+ return ConditionalResolution.NotApplicable(); // FM0062 — silent fallback
+
+ var predicateName = hasCondition ? conditionName! : skipWhenName!;
+ var kind = hasCondition ? ConditionalKind.Condition : ConditionalKind.SkipWhen;
+
+ ITypeSymbol expectedArgType;
+ if (kind == ConditionalKind.Condition)
+ {
+ string sourcePropPath = propertyMappings.TryGetValue(destProp.Name, out var mapped)
+ ? mapped
+ : destProp.Name;
+ expectedArgType = ResolvePathLeafType(sourcePropPath, (INamedTypeSymbol)sourceType) ?? sourceType;
+ }
+ else
+ {
+ expectedArgType = sourceType;
+ }
+
+ var predicate = forger.Symbol.GetMembers(predicateName).OfType().FirstOrDefault(m =>
+ m.Parameters.Length == 1 &&
+ m.ReturnType.SpecialType == SpecialType.System_Boolean &&
+ CanAssign(expectedArgType, m.Parameters[0].Type));
+
+ return predicate == null
+ ? ConditionalResolution.NotApplicable() // FM0061 — silent fallback
+ : ConditionalResolution.FromPredicate(kind, predicate, predicateName);
+ }
+```
+
+- [ ] **Step 4: Apply the wrap in `GenerateForgeMethod` writers**
+
+Open `src/ForgeMap.Generator/ForgeCodeEmitter.ForgeMethod.cs`. The three writers (`GenerateCtorPlusInitializer`, `GenerateObjInitWithAfterForge`, `GenerateSimpleObjectInit`) all call `GeneratePropertyAssignment` and then push successful results into an `initAssignments` / `afterForgeAssignments` / `plainAssignments` list. We need to detour conditional-eligible properties into the existing `skipNullAssignments` post-construction list instead.
+
+For each of the three writers, perform the same edit:
+
+ a. Add `cfg.ConditionMappings`, `cfg.SkipWhenMappings`, and `forger` to the parameter list (as `Dictionary? conditionMappings`, `Dictionary? skipWhenMappings`, and reuse the existing `forger` already in scope), so they can be threaded into `GeneratePropertyAssignment` and re-resolved.
+
+ b. Update the `GeneratePropertyAssignment(...)` call site to pass these new arguments.
+
+ c. After the call returns a non-null `assignment`, check whether the conditional applies. If it does, push to `skipNullAssignments…` instead of the initializer list:
+
+For `GenerateSimpleObjectInit` (around lines 471–545), modify the `foreach (var destProp in destProperties.Where(...))` body. After:
+
+```csharp
+ var assignment = GeneratePropertyAssignment(
+ destProp, sourceParam, sourceType, sourceProperties,
+ propertyMappings, resolverMappings, forgeWithMappings, ignoredProperties, forger, context, method,
+ nullPropertyHandlingOverrides, skipNullAssignmentsPlain,
+ postConstructionCollectionsPlain, preConstructionBlocksPlain, propertyConvertWithMappings, selectPropertyMappings,
+ conditionMappings, skipWhenMappings, /* isCtorBound: */ false, forger);
+
+ if (assignment != null)
+ {
+ var conditional = TryResolveConditionalSilently(
+ destProp, sourceType,
+ conditionMappings ?? new Dictionary(System.StringComparer.Ordinal),
+ skipWhenMappings ?? new Dictionary(System.StringComparer.Ordinal),
+ propertyMappings,
+ selectPropertyMappings ?? new Dictionary(System.StringComparer.Ordinal),
+ resolverMappings, forgeWithMappings,
+ isCtorBound: false, forger);
+ if (conditional.Applicable && !conditional.DidFail)
+ {
+ // Build the guard arg: Condition uses the source-property value;
+ // SkipWhen uses the source object.
+ string predicateArg;
+ if (conditional.Kind == ConditionalKind.Condition)
+ {
+ var srcPath = propertyMappings.TryGetValue(destProp.Name, out var mapped) ? mapped : destProp.Name;
+ var (srcExpr, _) = GenerateSourceExpressionWithNullInfo(sourceParam, srcPath, sourceType);
+ predicateArg = srcExpr;
+ }
+ else
+ {
+ predicateArg = sourceParam;
+ }
+ var guard = BuildConditionalGuardExpression(in conditional, predicateArg);
+ // Re-use the skipNull tuple shape: (destName, sourceExpr-for-pattern, localVar, assignExpr).
+ // We piggyback by placing the literal guard into the SourceExpr slot so the
+ // writer's `if ({srcExpr} is { } {localVar})` template becomes
+ // `if (Predicate(arg) is { } __cond_Prop)` ... not what we want. Instead,
+ // we use the postConstructionCollections list, which lets us emit an
+ // arbitrary multi-line block.
+ var block = $" if ({guard})\n {{\n result.{destProp.Name} = {assignment};\n }}";
+ postConstructionCollectionsPlain.Add((destProp.Name, block));
+ }
+ else
+ {
+ plainAssignments.Add((destProp.Name, assignment));
+ }
+ }
+```
+
+Replace the existing `if (assignment != null) plainAssignments.Add((destProp.Name, assignment));` block with the expanded version above. (The previous `assignment` call site already exists — only the `if (assignment != null)` body changes.)
+
+Apply the same shape to `GenerateObjInitWithAfterForge` (using `postConstructionCollectionsAfterForge` and `afterForgeAssignments`) and to `GenerateCtorPlusInitializer` (using `postConstructionCollectionsForCtor` and `initAssignments`). For `GenerateCtorPlusInitializer`, also pass `isCtorBound: ctorCoveredDestProps.Contains(destProp.Name)` so FM0062 fires for any conditional declared on a constructor-bound destination property; the `assignment` is then expected to be `null` for those (`GeneratePropertyAssignment` is not even called for ctor-covered props in this writer because of the `!ctorCoveredDestProps.Contains(p.Name)` filter on `remainingDestProps`). That filter means FM0062 for ctor-bound props must be emitted by a separate pre-pass:
+
+ d. **Pre-pass for FM0062 on ctor-bound props.** Before the `remainingDestProps` filter in `GenerateCtorPlusInitializer`, add:
+
+```csharp
+ // FM0062 pre-pass: any [ForgeProperty(Condition=...)] / SkipWhen targeting a
+ // ctor-bound destination property must be flagged before we filter those props
+ // out of the initializer loop.
+ if (conditionMappings != null || skipWhenMappings != null)
+ {
+ foreach (var destProp in destProperties)
+ {
+ if (!ctorCoveredDestProps.Contains(destProp.Name))
+ continue;
+ if ((conditionMappings != null && conditionMappings.ContainsKey(destProp.Name))
+ || (skipWhenMappings != null && skipWhenMappings.ContainsKey(destProp.Name)))
+ {
+ ReportDiagnosticIfNotSuppressed(context,
+ DiagnosticDescriptors.ConditionalNotSupportedOnInitOrCtor,
+ method.Locations.FirstOrDefault(), destProp.Name);
+ }
+ }
+ }
+```
+
+ e. **Update the writer call sites in `GenerateForgeMethod`.** Each of the three writer calls (around lines 175–193) currently passes `propertyConvertWithMappings, selectPropertyMappings`. Append `cfg.ConditionMappings, cfg.SkipWhenMappings` to each call (and update each writer's signature accordingly).
+
+- [ ] **Step 5: Build to confirm wiring compiles**
+
+Run: `dotnet build src/ForgeMap.Generator/ForgeMap.Generator.csproj`
+Expected: Build succeeded, 0 errors.
+
+- [ ] **Step 6: Build the full solution to make sure existing tests still link**
+
+Run: `dotnet build`
+Expected: Build succeeded, 0 errors.
+
+- [ ] **Step 7: Run the existing test suite to confirm zero regressions**
+
+Run: `dotnet test --filter "FullyQualifiedName!~ConditionalAssignment"`
+Expected: All existing tests pass. (We haven't added any conditional tests yet — Task 8.)
+
+- [ ] **Step 8: Commit**
+
+```bash
+git add src/ForgeMap.Generator/ForgeCodeEmitter.Conditional.cs src/ForgeMap.Generator/ForgeCodeEmitter.PropertyAssignment.cs src/ForgeMap.Generator/ForgeCodeEmitter.ForgeMethod.cs
+git commit -m "feat(v1.7): wire Condition/SkipWhen into Forge new-instance path"
+```
+
+---
+
+## Task 6: Wire conditional guards into `ForgeInto` (existing-target) methods — the primary use case
+
+**Files:**
+- Modify: `src/ForgeMap.Generator/ForgeCodeEmitter.ForgeIntoMethod.cs`
+
+`GenerateForgeIntoPropertyAssignment` writes statements directly to `sb`. The conditional guard wraps each statement-producing branch. Because the spec deliberately does NOT add a redundant `!= null` check around the predicate, we always emit a single `if (predicate(...))` statement that contains the assignment(s) the existing branches would have produced.
+
+- [ ] **Step 1: Add a small inline rewriter for `ForgeInto`**
+
+Open `src/ForgeMap.Generator/ForgeCodeEmitter.ForgeIntoMethod.cs`. At the top of `GenerateForgeIntoPropertyAssignment` (after the `if (ignoredProperties.Contains(destProp.Name)) return;`), insert:
+
+```csharp
+ // v1.7 Conditional (Condition / SkipWhen) for ForgeInto.
+ // Resolution validates and reports diagnostics; on Applicable+!Failed we
+ // wrap the per-property emission in a guard StringBuilder and replay it.
+ var conditionMappings = cfgConditionMappings ?? new Dictionary(StringComparer.Ordinal);
+ var skipWhenMappings = cfgSkipWhenMappings ?? new Dictionary(StringComparer.Ordinal);
+ var conditional = ResolveConditionalForProperty(
+ destProp, sourceType,
+ conditionMappings, skipWhenMappings,
+ propertyMappings,
+ cfgSelectPropertyMappings ?? new Dictionary(StringComparer.Ordinal),
+ resolverMappings, forgeWithMappings,
+ isCtorBound: false,
+ forger, context, method);
+
+ // On Failed, fall through and emit the unguarded assignment so output remains compilable.
+ StringBuilder? guardedSb = null;
+ StringBuilder workingSb = sb;
+ if (conditional.Applicable && !conditional.DidFail)
+ {
+ guardedSb = new StringBuilder();
+ workingSb = guardedSb;
+ }
+```
+
+Then, mechanically rename every `sb.Append…(` inside `GenerateForgeIntoPropertyAssignment`'s body to `workingSb.Append…(`. (Keep nested helpers like `GenerateExistingTargetBlock` as-is — those go into the writer's parent `sb` indirectly via `sb.AppendLine(etBlock);` which already uses `sb`. For the conditional path, the simplest correct change is to also flip `sb.AppendLine(etBlock);` to `workingSb.AppendLine(etBlock);` so an `ExistingTarget` property with a conditional gets wrapped together.)
+
+After every existing `return;` inside `GenerateForgeIntoPropertyAssignment`, just before each `return;`, insert a flush:
+
+```csharp
+ FlushConditionalGuard(sb, workingSb, conditional, sourceParam, destProp, propertyMappings, sourceNamedType);
+```
+
+And add the `FlushConditionalGuard` helper at the bottom of the partial class (in the same file):
+
+```csharp
+ private void FlushConditionalGuard(
+ StringBuilder destSb,
+ StringBuilder workingSb,
+ in ConditionalResolution conditional,
+ string sourceParam,
+ IPropertySymbol destProp,
+ Dictionary propertyMappings,
+ INamedTypeSymbol sourceNamedType)
+ {
+ if (!conditional.Applicable || conditional.DidFail || ReferenceEquals(destSb, workingSb))
+ return;
+
+ string predicateArg;
+ if (conditional.Kind == ConditionalKind.Condition)
+ {
+ var srcPath = propertyMappings.TryGetValue(destProp.Name, out var mapped) ? mapped : destProp.Name;
+ var (srcExpr, _) = GenerateSourceExpressionWithNullInfo(sourceParam, srcPath, sourceNamedType);
+ predicateArg = srcExpr;
+ }
+ else
+ {
+ predicateArg = sourceParam;
+ }
+
+ var guard = BuildConditionalGuardExpression(in conditional, predicateArg);
+ var inner = workingSb.ToString();
+ if (inner.Length == 0) return;
+
+ destSb.Append(" if (").Append(guard).AppendLine(")");
+ destSb.AppendLine(" {");
+ // Indent the captured inner block by 4 more spaces so it nests cleanly.
+ foreach (var line in inner.Split('\n'))
+ {
+ if (line.Length == 0) continue;
+ destSb.Append(" ").AppendLine(line.TrimEnd('\r'));
+ }
+ destSb.AppendLine(" }");
+ workingSb.Clear();
+ }
+```
+
+- [ ] **Step 2: Plumb the new `cfgConditionMappings` / `cfgSkipWhenMappings` / `cfgSelectPropertyMappings` parameters into `GenerateForgeIntoPropertyAssignment`**
+
+Locate the method signature for `GenerateForgeIntoPropertyAssignment` (currently around line 105). Append the three optional parameters at the end:
+
+```csharp
+ Dictionary? cfgConditionMappings = null,
+ Dictionary? cfgSkipWhenMappings = null,
+ Dictionary? cfgSelectPropertyMappings = null)
+```
+
+In the parent `GenerateForgeIntoMethod` (around line 80), update the call site to pass `cfg.ConditionMappings, cfg.SkipWhenMappings, cfg.SelectPropertyMappings`.
+
+- [ ] **Step 3: Build to confirm `ForgeInto` wiring compiles**
+
+Run: `dotnet build src/ForgeMap.Generator/ForgeMap.Generator.csproj`
+Expected: Build succeeded, 0 errors.
+
+- [ ] **Step 4: Run existing tests to confirm zero regressions**
+
+Run: `dotnet test --filter "FullyQualifiedName!~ConditionalAssignment"`
+Expected: All existing tests pass.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/ForgeMap.Generator/ForgeCodeEmitter.ForgeIntoMethod.cs
+git commit -m "feat(v1.7): wire Condition/SkipWhen into ForgeInto existing-target path"
+```
+
+---
+
+## Task 7: Add the conditional test file
+
+**Files:**
+- Create: `tests/ForgeMap.Tests/ConditionalAssignmentGeneratorTests.cs`
+
+Each test in this file follows the established pattern: declare a small program string, run it through the source generator, and assert on the generated text or diagnostics.
+
+- [ ] **Step 1: Create the test file with the full test set**
+
+Create `tests/ForgeMap.Tests/ConditionalAssignmentGeneratorTests.cs` with these contents:
+
+```csharp
+using Microsoft.CodeAnalysis;
+using Xunit;
+using static ForgeMap.Tests.TestHelper;
+
+namespace ForgeMap.Tests;
+
+public class ConditionalAssignmentGeneratorTests
+{
+ [Fact]
+ public void Condition_OnForgeInto_EmitsGuardedAssignment()
+ {
+ var source = @"
+using ForgeMap;
+
+public class Src { public string? Name { get; set; } }
+public class Dst { public string? Name { get; set; } }
+
+[ForgeMap]
+public partial class M
+{
+ [ForgeProperty(nameof(Src.Name), nameof(Dst.Name), Condition = nameof(IsNotNull))]
+ public partial void ForgeInto(Src source, [UseExistingValue] Dst destination);
+
+ private static bool IsNotNull(string? v) => v is not null;
+}";
+ var (diagnostics, trees) = RunGenerator(source);
+ Assert.Empty(diagnostics.Where(d => d.Severity == DiagnosticSeverity.Error));
+ var generated = string.Join("\n", trees.Select(t => t.GetText().ToString()));
+ Assert.Contains("if (IsNotNull(source.Name))", generated);
+ Assert.Contains("destination.Name = source.Name", generated);
+ }
+
+ [Fact]
+ public void SkipWhen_OnForgeInto_EmitsNegatedGuard()
+ {
+ var source = @"
+using ForgeMap;
+
+public class Src { public int Id { get; set; } }
+public class Dst { public int Id { get; set; } }
+
+[ForgeMap]
+public partial class M
+{
+ [ForgeProperty(nameof(Src.Id), nameof(Dst.Id), SkipWhen = nameof(IdIsZero))]
+ public partial void ForgeInto(Src source, [UseExistingValue] Dst destination);
+
+ private static bool IdIsZero(Src s) => s.Id == 0;
+}";
+ var (diagnostics, trees) = RunGenerator(source);
+ Assert.Empty(diagnostics.Where(d => d.Severity == DiagnosticSeverity.Error));
+ var generated = string.Join("\n", trees.Select(t => t.GetText().ToString()));
+ Assert.Contains("if (!IdIsZero(source))", generated);
+ Assert.Contains("destination.Id = source.Id", generated);
+ }
+
+ [Fact]
+ public void Condition_OnForge_EmitsPostConstructionGuard()
+ {
+ var source = @"
+using ForgeMap;
+
+public class Src { public string? Protocol { get; set; } }
+public class Dst { public string Protocol { get; set; } = ""default""; }
+
+[ForgeMap]
+public partial class M
+{
+ [ForgeProperty(nameof(Src.Protocol), nameof(Dst.Protocol), Condition = nameof(IsNotNull))]
+ public partial Dst Forge(Src source);
+
+ private static bool IsNotNull(string? v) => v is not null;
+}";
+ var (diagnostics, trees) = RunGenerator(source);
+ Assert.Empty(diagnostics.Where(d => d.Severity == DiagnosticSeverity.Error));
+ var generated = string.Join("\n", trees.Select(t => t.GetText().ToString()));
+ // Must use a result variable + post-construction if-guard, not an initializer.
+ Assert.Contains("var result = new", generated);
+ Assert.Contains("if (IsNotNull(source.Protocol))", generated);
+ Assert.Contains("result.Protocol = source.Protocol", generated);
+ }
+
+ [Fact]
+ public void Condition_BothConditionAndSkipWhen_EmitsFM0060()
+ {
+ var source = @"
+using ForgeMap;
+
+public class Src { public string? Name { get; set; } }
+public class Dst { public string? Name { get; set; } }
+
+[ForgeMap]
+public partial class M
+{
+ [ForgeProperty(nameof(Src.Name), nameof(Dst.Name),
+ Condition = nameof(IsNotNull), SkipWhen = nameof(IsEmptySrc))]
+ public partial void ForgeInto(Src source, [UseExistingValue] Dst destination);
+
+ private static bool IsNotNull(string? v) => v is not null;
+ private static bool IsEmptySrc(Src s) => s.Name is null;
+}";
+ var (diagnostics, _) = RunGenerator(source);
+ Assert.Contains(diagnostics, d => d.Id == "FM0060");
+ }
+
+ [Fact]
+ public void Condition_PredicateNotFound_EmitsFM0061()
+ {
+ var source = @"
+using ForgeMap;
+
+public class Src { public string? Name { get; set; } }
+public class Dst { public string? Name { get; set; } }
+
+[ForgeMap]
+public partial class M
+{
+ [ForgeProperty(nameof(Src.Name), nameof(Dst.Name), Condition = ""DoesNotExist"")]
+ public partial void ForgeInto(Src source, [UseExistingValue] Dst destination);
+}";
+ var (diagnostics, _) = RunGenerator(source);
+ Assert.Contains(diagnostics, d => d.Id == "FM0061");
+ }
+
+ [Fact]
+ public void Condition_PredicateWrongReturnType_EmitsFM0061()
+ {
+ var source = @"
+using ForgeMap;
+
+public class Src { public string? Name { get; set; } }
+public class Dst { public string? Name { get; set; } }
+
+[ForgeMap]
+public partial class M
+{
+ [ForgeProperty(nameof(Src.Name), nameof(Dst.Name), Condition = nameof(NotABool))]
+ public partial void ForgeInto(Src source, [UseExistingValue] Dst destination);
+
+ private static int NotABool(string? v) => 0;
+}";
+ var (diagnostics, _) = RunGenerator(source);
+ Assert.Contains(diagnostics, d => d.Id == "FM0061");
+ }
+
+ [Fact]
+ public void Condition_OnInitOnlyProperty_EmitsFM0062()
+ {
+ var source = @"
+using ForgeMap;
+
+public class Src { public string Name { get; set; } = """"; }
+public class Dst { public string Name { get; init; } = """"; }
+
+[ForgeMap]
+public partial class M
+{
+ [ForgeProperty(nameof(Src.Name), nameof(Dst.Name), Condition = nameof(IsNotEmpty))]
+ public partial Dst Forge(Src source);
+
+ private static bool IsNotEmpty(string v) => v.Length > 0;
+}";
+ var (diagnostics, _) = RunGenerator(source);
+ Assert.Contains(diagnostics, d => d.Id == "FM0062");
+ }
+
+ [Fact]
+ public void Condition_OnConstructorParameter_EmitsFM0062()
+ {
+ var source = @"
+using ForgeMap;
+
+public class Src { public string Name { get; set; } = """"; }
+public class Dst
+{
+ public Dst(string name) { Name = name; }
+ public string Name { get; }
+}
+
+[ForgeMap]
+public partial class M
+{
+ [ForgeProperty(nameof(Src.Name), nameof(Dst.Name), Condition = nameof(IsNotEmpty))]
+ public partial Dst Forge(Src source);
+
+ private static bool IsNotEmpty(string v) => v.Length > 0;
+}";
+ var (diagnostics, _) = RunGenerator(source);
+ Assert.Contains(diagnostics, d => d.Id == "FM0062");
+ }
+
+ [Fact]
+ public void Condition_WithForgeFrom_EmitsFM0063()
+ {
+ var source = @"
+using ForgeMap;
+
+public class Src { public string? Name { get; set; } }
+public class Dst { public string? Name { get; set; } }
+
+[ForgeMap]
+public partial class M
+{
+ [ForgeProperty(nameof(Src.Name), nameof(Dst.Name), Condition = nameof(IsNotNull))]
+ [ForgeFrom(nameof(Dst.Name), nameof(Resolve))]
+ public partial void ForgeInto(Src source, [UseExistingValue] Dst destination);
+
+ private static bool IsNotNull(string? v) => v is not null;
+ private static string? Resolve(Src s) => s.Name;
+}";
+ var (diagnostics, _) = RunGenerator(source);
+ Assert.Contains(diagnostics, d => d.Id == "FM0063");
+ }
+
+ [Fact]
+ public void Condition_WithSelectProperty_FM0063_SuppressedByFM0072()
+ {
+ var source = @"
+using System.Collections.Generic;
+using ForgeMap;
+
+public class Tag { public string Name { get; set; } = """"; }
+public class Src { public List? Tags { get; set; } }
+public class Dst { public List? Tags { get; set; } }
+
+[ForgeMap]
+public partial class M
+{
+ [ForgeProperty(nameof(Src.Tags), nameof(Dst.Tags),
+ SelectProperty = nameof(Tag.Name),
+ Condition = nameof(HasItems))]
+ [ForgeFrom(nameof(Dst.Tags), nameof(Resolve))]
+ public partial void ForgeInto(Src source, [UseExistingValue] Dst destination);
+
+ private static bool HasItems(List? t) => t is { Count: > 0 };
+ private static List Resolve(Src s) => new();
+}";
+ var (diagnostics, _) = RunGenerator(source);
+ // FM0072 fires for the SelectProperty/ForgeFrom conflict; FM0063 must be suppressed.
+ Assert.Contains(diagnostics, d => d.Id == "FM0072");
+ Assert.DoesNotContain(diagnostics, d => d.Id == "FM0063");
+ }
+
+ [Fact]
+ public void Condition_ComposedWithConvertWith_GuardWrapsConverter()
+ {
+ var source = @"
+using ForgeMap;
+
+public class Src { public string? Raw { get; set; } }
+public class Dst { public int Cooked { get; set; } }
+
+[ForgeMap]
+public partial class M
+{
+ [ForgeProperty(nameof(Src.Raw), nameof(Dst.Cooked),
+ Condition = nameof(IsNumber),
+ ConvertWith = nameof(Parse))]
+ public partial void ForgeInto(Src source, [UseExistingValue] Dst destination);
+
+ private static bool IsNumber(string? v) => int.TryParse(v, out _);
+ private static int Parse(string s) => int.Parse(s);
+}";
+ var (diagnostics, trees) = RunGenerator(source);
+ Assert.Empty(diagnostics.Where(d => d.Severity == DiagnosticSeverity.Error));
+ var generated = string.Join("\n", trees.Select(t => t.GetText().ToString()));
+ Assert.Contains("if (IsNumber(source.Raw))", generated);
+ Assert.Contains("Parse(", generated);
+ }
+
+ [Fact]
+ public void SkipWhen_OnIgnoredProperty_NoDiagnostic_NoEmit()
+ {
+ var source = @"
+using ForgeMap;
+
+public class Src { public int Id { get; set; } }
+public class Dst { public int Id { get; set; } }
+
+[ForgeMap]
+public partial class M
+{
+ [Ignore(nameof(Dst.Id))]
+ [ForgeProperty(nameof(Src.Id), nameof(Dst.Id), SkipWhen = nameof(IdIsZero))]
+ public partial void ForgeInto(Src source, [UseExistingValue] Dst destination);
+
+ private static bool IdIsZero(Src s) => s.Id == 0;
+}";
+ var (diagnostics, trees) = RunGenerator(source);
+ Assert.Empty(diagnostics.Where(d => d.Severity == DiagnosticSeverity.Error));
+ var generated = string.Join("\n", trees.Select(t => t.GetText().ToString()));
+ Assert.DoesNotContain("IdIsZero", generated);
+ Assert.DoesNotContain("destination.Id = source.Id", generated);
+ }
+
+ [Fact]
+ public void Condition_DoesNotPropagateThroughReverseForge()
+ {
+ // [ReverseForge] generates the inverse mapping. Per spec, Condition/SkipWhen
+ // are NOT auto-reversed; the reverse method must be plain assignment.
+ var source = @"
+using ForgeMap;
+
+public class Src { public string? Name { get; set; } }
+public class Dst { public string? Name { get; set; } }
+
+[ForgeMap]
+public partial class M
+{
+ [ForgeProperty(nameof(Src.Name), nameof(Dst.Name), Condition = nameof(IsNotNull))]
+ [ReverseForge]
+ public partial Dst Forge(Src source);
+ public partial Src ForgeReverse(Dst source);
+
+ private static bool IsNotNull(string? v) => v is not null;
+}";
+ var (diagnostics, trees) = RunGenerator(source);
+ Assert.Empty(diagnostics.Where(d => d.Severity == DiagnosticSeverity.Error));
+ var generated = string.Join("\n", trees.Select(t => t.GetText().ToString()));
+ // Forward path: guarded.
+ Assert.Contains("if (IsNotNull(source.Name))", generated);
+ // Reverse path: ForgeReverse body must NOT contain the predicate call.
+ var reverseStart = generated.IndexOf("ForgeReverse");
+ Assert.True(reverseStart > 0, "Generated code should contain ForgeReverse body");
+ var reverseBody = generated.Substring(reverseStart);
+ Assert.DoesNotContain("IsNotNull(", reverseBody);
+ }
+}
+```
+
+- [ ] **Step 2: Run the conditional test suite**
+
+Run: `dotnet test --filter "FullyQualifiedName~ConditionalAssignmentGeneratorTests"`
+Expected: All 13 tests pass.
+
+If any test fails, the most likely causes are:
+
+ - `FM0063 not suppressed by FM0072`: re-check `ResolveConditionalForProperty` — the early return for `selectPropertyMappings.ContainsKey(destProp.Name)` must skip the FM0063 report.
+ - `Condition_OnForge_EmitsPostConstructionGuard`: the writer must use `var result = new …;` form (i.e. `needsResultVar` must be true). This happens automatically when `postConstructionCollectionsPlain.Count > 0`, which is what Task 5 Step 4(c) added.
+ - `Condition_DoesNotPropagateThroughReverseForge`: this confirms that `ForgeCodeEmitter.ReverseMethod.cs` does NOT receive conditional config. Because `ResolveMethodConfig` runs separately for the reverse method on its own attribute set, and the user did not declare conditional on the reverse partial, the reverse path should naturally be plain. If the test fails, the reverse generator may be inheriting attributes — check whether reverse method inherits `[ForgeProperty]` config and add filtering if so.
+
+- [ ] **Step 3: Run the entire test suite to confirm no regressions**
+
+Run: `dotnet test`
+Expected: All tests pass.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add tests/ForgeMap.Tests/ConditionalAssignmentGeneratorTests.cs
+git commit -m "test(v1.7): cover Condition/SkipWhen across diagnostics, Forge, ForgeInto, composition"
+```
+
+---
+
+## Task 8: Final integration build and PR-ready verification
+
+**Files:**
+- (No code changes — verification only.)
+
+- [ ] **Step 1: Clean rebuild from scratch**
+
+Run: `dotnet build -c Release`
+Expected: Build succeeded, 0 errors, 0 warnings.
+
+- [ ] **Step 2: Run full test suite in Release**
+
+Run: `dotnet test -c Release`
+Expected: All tests pass.
+
+- [ ] **Step 3: Confirm no spec-listed diagnostic IDs were missed**
+
+Open `docs/SPEC-v1.7-projection-and-conditional.md` and scan the Feature 2 Diagnostics table. Confirm FM0060–FM0064 are all present in:
+ - `src/ForgeMap.Generator/DiagnosticDescriptors.cs`
+ - `src/ForgeMap.Generator/AnalyzerReleases.Unshipped.md`
+
+Run: `grep -E "FM006[0-4]" src/ForgeMap.Generator/DiagnosticDescriptors.cs src/ForgeMap.Generator/AnalyzerReleases.Unshipped.md`
+Expected: 5 IDs in each file (FM0060, FM0061, FM0062, FM0063, FM0064).
+
+- [ ] **Step 4: Confirm no stray `TODO`/`FIXME` left behind**
+
+Run: `grep -rn "TODO\|FIXME" src/ForgeMap.Generator/ForgeCodeEmitter.Conditional.cs`
+Expected: No matches.
+
+- [ ] **Step 5 (optional): Update spec status**
+
+Open `docs/SPEC-v1.7-projection-and-conditional.md`. In the Overview table at the top, change Feature 2's Status from `Planned` to `Implemented`.
+
+Skip this step if the project does not yet have a convention for marking spec features as implemented — the existing Feature 1 row may still say "Planned". Match whatever convention has been used.
+
+- [ ] **Step 6: Commit any final touch-ups**
+
+```bash
+git status
+# If anything beyond intentional changes is pending, decide whether to commit or revert.
+git add -A
+git commit -m "chore(v1.7): finalize Condition/SkipWhen feature"
+```
+
+(Skip this commit if nothing is pending.)
+
+---
+
+## Out of scope (do NOT implement)
+
+- Rewriting the existing `SelectProperty` projection path. Conditional composes with it via the lambda body in the existing projection generator; no projection-side changes are needed.
+- Auto-reversing predicates via `[ReverseForge]`. Per spec, this is intentionally NOT done. The test in Task 7 Step 1 codifies that.
+- Supporting `Condition`/`SkipWhen` on collection element forge methods. The spec scopes this to per-property `[ForgeProperty]` only.
+- Supporting predicates on `init`/`required`/ctor params via any clever routing. Spec is explicit: emit FM0062 and stop.
+- Supporting `Condition`/`SkipWhen` on `[ForgeFrom]`/`[ForgeWith]` mappings. Spec is explicit: emit FM0063 and stop.
+- Adding a "skip-null" shortcut for `Condition` (e.g., omitting the predicate to mean "skip when null"). Users should use `NullPropertyHandling.SkipNull` for that case (already supported).
+
+---
+
+*Plan version: 1.0 (2026-04-21)*
+*Spec reference: `docs/SPEC-v1.7-projection-and-conditional.md` Feature 2*
+*Predecessor PR (Feature 1, SelectProperty): #136 (commit e16561d)*
diff --git a/src/ForgeMap.Abstractions/ForgePropertyAttribute.cs b/src/ForgeMap.Abstractions/ForgePropertyAttribute.cs
index b502261..595de44 100644
--- a/src/ForgeMap.Abstractions/ForgePropertyAttribute.cs
+++ b/src/ForgeMap.Abstractions/ForgePropertyAttribute.cs
@@ -90,4 +90,24 @@ public ForgePropertyAttribute(string sourceProperty, string destinationProperty)
/// on the same destination property.
///
public string? SelectProperty { get; set; }
+
+ ///
+ /// Name of a predicate method on the forger class. Called with the source property value;
+ /// when it returns false, the destination assignment is skipped.
+ /// Signature: bool MethodName(TSourceProperty value). Static or instance, any accessibility.
+ /// Mutually exclusive with on the same [ForgeProperty].
+ /// Cannot be combined with [ForgeFrom] or [ForgeWith] on the same destination property.
+ /// Not supported on properties bound to a constructor parameter, an init setter, or a required member.
+ ///
+ public string? Condition { get; set; }
+
+ ///
+ /// Name of a predicate method on the forger class. Called with the source object;
+ /// when it returns true, the destination assignment is skipped.
+ /// Signature: bool MethodName(TSource source). Static or instance, any accessibility.
+ /// Mutually exclusive with on the same [ForgeProperty].
+ /// Cannot be combined with [ForgeFrom] or [ForgeWith] on the same destination property.
+ /// Not supported on properties bound to a constructor parameter, an init setter, or a required member.
+ ///
+ public string? SkipWhen { get; set; }
}
diff --git a/src/ForgeMap.Generator/AnalyzerReleases.Unshipped.md b/src/ForgeMap.Generator/AnalyzerReleases.Unshipped.md
index 876a912..794622e 100644
--- a/src/ForgeMap.Generator/AnalyzerReleases.Unshipped.md
+++ b/src/ForgeMap.Generator/AnalyzerReleases.Unshipped.md
@@ -10,6 +10,11 @@ FM0056 | ForgeMap | Error | SelectPropertyMemberNotFound
FM0057 | ForgeMap | Error | SelectPropertyElementTypeIncompatible
FM0058 | ForgeMap | Error | SelectPropertyConflictsWithConverter
FM0059 | ForgeMap | Disabled | SelectPropertyApplied
+FM0060 | ForgeMap | Error | ConditionAndSkipWhenBothSet
+FM0061 | ForgeMap | Error | ConditionalPredicateInvalid
+FM0062 | ForgeMap | Error | ConditionalNotSupportedOnInitOrCtor
+FM0063 | ForgeMap | Error | ConditionalConflictsWithForgeFromOrWith
+FM0064 | ForgeMap | Disabled | ConditionalAssignmentApplied
FM0072 | ForgeMap | Error | SelectPropertyConflictsWithForgeFromOrWith
FM0073 | ForgeMap | Error | SelectPropertyDestinationNotEnumerable
FM0075 | ForgeMap | Warning | SelectPropertyNotSupportedOnForgeInto
diff --git a/src/ForgeMap.Generator/DiagnosticDescriptors.cs b/src/ForgeMap.Generator/DiagnosticDescriptors.cs
index 72ec28b..e42d579 100644
--- a/src/ForgeMap.Generator/DiagnosticDescriptors.cs
+++ b/src/ForgeMap.Generator/DiagnosticDescriptors.cs
@@ -504,4 +504,44 @@ internal static class DiagnosticDescriptors
category: Category,
defaultSeverity: DiagnosticSeverity.Warning,
isEnabledByDefault: true);
+
+ public static readonly DiagnosticDescriptor ConditionAndSkipWhenBothSet = new(
+ id: "FM0060",
+ title: "Condition and SkipWhen are mutually exclusive",
+ messageFormat: "Property '{0}' has both Condition and SkipWhen set on the same [ForgeProperty] — choose one",
+ category: Category,
+ defaultSeverity: DiagnosticSeverity.Error,
+ isEnabledByDefault: true);
+
+ public static readonly DiagnosticDescriptor ConditionalPredicateInvalid = new(
+ id: "FM0061",
+ title: "Conditional predicate method not found or has wrong signature",
+ messageFormat: "Predicate method '{0}' for property '{1}' was not found on the forger class or has the wrong signature; expected: bool {0}({2})",
+ category: Category,
+ defaultSeverity: DiagnosticSeverity.Error,
+ isEnabledByDefault: true);
+
+ public static readonly DiagnosticDescriptor ConditionalNotSupportedOnInitOrCtor = new(
+ id: "FM0062",
+ title: "Condition/SkipWhen cannot be applied to init, required, or constructor-bound properties",
+ messageFormat: "Condition/SkipWhen cannot be applied to property '{0}' because it is set via constructor or init/required. Use [ForgeFrom] to choose between alternative constructions, or drop init if you need conditional post-construction assignment.",
+ category: Category,
+ defaultSeverity: DiagnosticSeverity.Error,
+ isEnabledByDefault: true);
+
+ public static readonly DiagnosticDescriptor ConditionalConflictsWithForgeFromOrWith = new(
+ id: "FM0063",
+ title: "Condition/SkipWhen conflicts with [ForgeFrom] / [ForgeWith]",
+ messageFormat: "Property '{0}' has Condition/SkipWhen and is also targeted by [ForgeFrom] or [ForgeWith] — choose one",
+ category: Category,
+ defaultSeverity: DiagnosticSeverity.Error,
+ isEnabledByDefault: true);
+
+ public static readonly DiagnosticDescriptor ConditionalAssignmentApplied = new(
+ id: "FM0064",
+ title: "Conditional assignment applied for property",
+ messageFormat: "Conditional assignment applied for property '{0}' via '{1}'",
+ category: Category,
+ defaultSeverity: DiagnosticSeverity.Info,
+ isEnabledByDefault: false);
}
diff --git a/src/ForgeMap.Generator/ForgeCodeEmitter.Conditional.cs b/src/ForgeMap.Generator/ForgeCodeEmitter.Conditional.cs
new file mode 100644
index 0000000..863259a
--- /dev/null
+++ b/src/ForgeMap.Generator/ForgeCodeEmitter.Conditional.cs
@@ -0,0 +1,328 @@
+using System.Collections.Generic;
+using System.Linq;
+using Microsoft.CodeAnalysis;
+using static ForgeMap.Generator.TypeAnalysisHelper;
+
+namespace ForgeMap.Generator;
+
+internal sealed partial class ForgeCodeEmitter
+{
+ ///
+ /// Conditional mode — Condition or SkipWhen.
+ ///
+ internal enum ConditionalKind
+ {
+ Condition, // predicate true => assign
+ SkipWhen, // predicate true => skip
+ }
+
+ ///
+ /// Result of resolving a per-property predicate. Indicates whether a guard
+ /// applies, and if so the predicate identity needed to emit the guard expression.
+ ///
+ internal readonly struct ConditionalResolution
+ {
+ private ConditionalResolution(bool applicable, bool failed, ConditionalKind kind, IMethodSymbol? predicate, string? predicateName)
+ {
+ Applicable = applicable;
+ DidFail = failed;
+ Kind = kind;
+ Predicate = predicate;
+ PredicateName = predicateName;
+ }
+
+ public bool Applicable { get; }
+ public bool DidFail { get; }
+ public ConditionalKind Kind { get; }
+ public IMethodSymbol? Predicate { get; }
+ public string? PredicateName { get; }
+
+ public static ConditionalResolution NotApplicable() => new(false, false, default, null, null);
+ public static ConditionalResolution Failed() => new(true, true, default, null, null);
+ public static ConditionalResolution FromPredicate(ConditionalKind kind, IMethodSymbol method, string name)
+ => new(true, false, kind, method, name);
+ }
+
+ ///
+ /// Resolves the per-property conditional configuration for a destination property:
+ /// validates exclusivity (FM0060), conflict with [ForgeFrom]/[ForgeWith] (FM0063
+ /// — suppressed when SelectProperty conflict already triggered FM0072), the destination
+ /// shape (FM0062 for ctor/init/required), and the predicate signature (FM0061).
+ /// Reports diagnostics directly. Returns NotApplicable when no Condition/SkipWhen
+ /// is set; Failed when a diagnostic was emitted; FromPredicate with the resolved
+ /// predicate method when emit may proceed.
+ ///
+ private ConditionalResolution ResolveConditionalForProperty(
+ IPropertySymbol destProp,
+ ITypeSymbol sourceType,
+ Dictionary conditionMappings,
+ Dictionary skipWhenMappings,
+ Dictionary propertyMappings,
+ Dictionary selectPropertyMappings,
+ Dictionary resolverMappings,
+ Dictionary forgeWithMappings,
+ bool isCtorBound,
+ ForgerInfo forger,
+ SourceProductionContext context,
+ IMethodSymbol method)
+ {
+ var hasCondition = conditionMappings.TryGetValue(destProp.Name, out var conditionName);
+ var hasSkipWhen = skipWhenMappings.TryGetValue(destProp.Name, out var skipWhenName);
+ if (!hasCondition && !hasSkipWhen)
+ return ConditionalResolution.NotApplicable();
+
+ var location = method.Locations.FirstOrDefault();
+
+ // FM0060: mutual exclusivity
+ if (hasCondition && hasSkipWhen)
+ {
+ ReportDiagnosticIfNotSuppressed(context,
+ DiagnosticDescriptors.ConditionAndSkipWhenBothSet,
+ location, destProp.Name);
+ return ConditionalResolution.Failed();
+ }
+
+ // FM0063 (suppressed by FM0072 precedence): conflict with [ForgeFrom] / [ForgeWith]
+ var hasForgeFromOrWith = resolverMappings.ContainsKey(destProp.Name) || forgeWithMappings.ContainsKey(destProp.Name);
+ if (hasForgeFromOrWith)
+ {
+ // Per spec: when SelectProperty also conflicts with ForgeFrom/ForgeWith,
+ // FM0072 wins and FM0063 is suppressed for this destination property.
+ if (!selectPropertyMappings.ContainsKey(destProp.Name))
+ {
+ ReportDiagnosticIfNotSuppressed(context,
+ DiagnosticDescriptors.ConditionalConflictsWithForgeFromOrWith,
+ location, destProp.Name);
+ }
+ return ConditionalResolution.Failed();
+ }
+
+ // FM0062: destination must be a settable, non-init, non-required, non-ctor-bound property
+ var isInitOnly = destProp.SetMethod?.IsInitOnly == true;
+ var isRequired = destProp.IsRequired;
+ if (isCtorBound || isInitOnly || isRequired)
+ {
+ ReportDiagnosticIfNotSuppressed(context,
+ DiagnosticDescriptors.ConditionalNotSupportedOnInitOrCtor,
+ location, destProp.Name);
+ return ConditionalResolution.Failed();
+ }
+
+ var predicateName = hasCondition ? conditionName! : skipWhenName!;
+ var kind = hasCondition ? ConditionalKind.Condition : ConditionalKind.SkipWhen;
+
+ ITypeSymbol expectedArgType;
+ if (kind == ConditionalKind.Condition)
+ {
+ string sourcePropPath = ResolveSourcePropertyPath(destProp, sourceType, propertyMappings);
+ expectedArgType = sourceType is INamedTypeSymbol named
+ ? ResolvePathLeafType(sourcePropPath, named) ?? sourceType
+ : sourceType;
+ }
+ else
+ {
+ expectedArgType = sourceType;
+ }
+
+ var candidates = forger.Symbol.GetMembers(predicateName).OfType().ToList();
+ var predicate = candidates.FirstOrDefault(m =>
+ m.Parameters.Length == 1 &&
+ m.ReturnType.SpecialType == SpecialType.System_Boolean &&
+ CanAssign(expectedArgType, m.Parameters[0].Type));
+
+ if (predicate == null)
+ {
+ ReportDiagnosticIfNotSuppressed(context,
+ DiagnosticDescriptors.ConditionalPredicateInvalid,
+ location, predicateName, destProp.Name, expectedArgType.ToDisplayString());
+ return ConditionalResolution.Failed();
+ }
+
+ // FM0064 is reported by callers only after they've actually emitted the
+ // guard-wrapped assignment block — predicate resolution alone doesn't
+ // guarantee emission (mapping may still fail or be skipped downstream).
+ return ConditionalResolution.FromPredicate(kind, predicate, predicateName);
+ }
+
+ ///
+ /// Reports FM0064 — call from emit sites only after the guarded assignment block
+ /// has actually been written, so the diagnostic accurately reflects emitted output.
+ ///
+ private void ReportConditionalAssignmentApplied(SourceProductionContext context, IMethodSymbol method, IPropertySymbol destProp, in ConditionalResolution resolution)
+ {
+ if (!resolution.Applicable || resolution.DidFail || resolution.PredicateName == null) return;
+ ReportDiagnosticIfNotSuppressed(context,
+ DiagnosticDescriptors.ConditionalAssignmentApplied,
+ method.Locations.FirstOrDefault(), destProp.Name, resolution.PredicateName);
+ }
+
+ ///
+ /// Re-resolves the conditional predicate for a destination property *without*
+ /// reporting diagnostics. Used by the Forge writer after GeneratePropertyAssignment
+ /// has already validated the configuration and reported any errors.
+ ///
+ [System.Diagnostics.CodeAnalysis.SuppressMessage("Style", "IDE0060:Remove unused parameter", Justification = "Kept symmetric with ResolveConditionalForProperty for caller convenience.")]
+ private ConditionalResolution TryResolveConditionalSilently(
+ IPropertySymbol destProp,
+ ITypeSymbol sourceType,
+ Dictionary conditionMappings,
+ Dictionary skipWhenMappings,
+ Dictionary propertyMappings,
+ Dictionary selectPropertyMappings,
+ Dictionary resolverMappings,
+ Dictionary forgeWithMappings,
+ bool isCtorBound,
+ ForgerInfo forger)
+ {
+ var hasCondition = conditionMappings.TryGetValue(destProp.Name, out var conditionName);
+ var hasSkipWhen = skipWhenMappings.TryGetValue(destProp.Name, out var skipWhenName);
+ if (!hasCondition && !hasSkipWhen) return ConditionalResolution.NotApplicable();
+ if (hasCondition && hasSkipWhen) return ConditionalResolution.NotApplicable();
+ if (resolverMappings.ContainsKey(destProp.Name) || forgeWithMappings.ContainsKey(destProp.Name))
+ return ConditionalResolution.NotApplicable();
+ if (isCtorBound || destProp.SetMethod?.IsInitOnly == true || destProp.IsRequired)
+ return ConditionalResolution.NotApplicable();
+
+ var predicateName = hasCondition ? conditionName! : skipWhenName!;
+ var kind = hasCondition ? ConditionalKind.Condition : ConditionalKind.SkipWhen;
+
+ ITypeSymbol expectedArgType;
+ if (kind == ConditionalKind.Condition)
+ {
+ string sourcePropPath = ResolveSourcePropertyPath(destProp, sourceType, propertyMappings);
+ expectedArgType = sourceType is INamedTypeSymbol named
+ ? ResolvePathLeafType(sourcePropPath, named) ?? sourceType
+ : sourceType;
+ }
+ else
+ {
+ expectedArgType = sourceType;
+ }
+
+ var predicate = forger.Symbol.GetMembers(predicateName).OfType().FirstOrDefault(m =>
+ m.Parameters.Length == 1 &&
+ m.ReturnType.SpecialType == SpecialType.System_Boolean &&
+ CanAssign(expectedArgType, m.Parameters[0].Type));
+
+ return predicate == null
+ ? ConditionalResolution.NotApplicable()
+ : ConditionalResolution.FromPredicate(kind, predicate, predicateName);
+ }
+
+ ///
+ /// Resolves the dotted source-property path used to locate the value passed to a Condition
+ /// predicate. Honors explicit [ForgeProperty(source, dest)] mappings and falls back to
+ /// convention matching that respects PropertyMatching (case-insensitive when configured).
+ ///
+ private string ResolveSourcePropertyPath(IPropertySymbol destProp, ITypeSymbol sourceType, Dictionary propertyMappings)
+ {
+ if (propertyMappings.TryGetValue(destProp.Name, out var mapped))
+ return mapped;
+
+ if (sourceType is INamedTypeSymbol named)
+ {
+ var matched = GetMappableProperties(named)
+ .FirstOrDefault(p => string.Equals(p.Name, destProp.Name, _config.PropertyNameComparison));
+ if (matched != null)
+ return matched.Name;
+ }
+
+ return destProp.Name;
+ }
+
+ ///
+ /// Builds the guard expression text used in if (<guard>) ...
+ /// for a resolved conditional. SkipWhen is negated.
+ ///
+ private static string BuildConditionalGuardExpression(in ConditionalResolution resolution, string argExpr)
+ {
+ var call = $"{resolution.PredicateName}({argExpr})";
+ return resolution.Kind == ConditionalKind.SkipWhen ? $"!{call}" : call;
+ }
+
+ ///
+ /// Resolves the per-property conditional configuration silently and derives the
+ /// predicate argument expression used in the guard. Returns the resolution and
+ /// (when applicable) the source-side expression for Condition or the source
+ /// parameter name for SkipWhen. predicateArg is null when the conditional is
+ /// not applicable or failed.
+ ///
+ private (ConditionalResolution Resolution, string? PredicateArg) ResolveConditionalAndPredicateArg(
+ IPropertySymbol destProp,
+ INamedTypeSymbol sourceType,
+ string sourceParam,
+ Dictionary conditionMappings,
+ Dictionary skipWhenMappings,
+ Dictionary propertyMappings,
+ Dictionary selectPropertyMappings,
+ Dictionary resolverMappings,
+ Dictionary forgeWithMappings,
+ ForgerInfo forger)
+ {
+ var conditional = TryResolveConditionalSilently(
+ destProp, sourceType,
+ conditionMappings,
+ skipWhenMappings,
+ propertyMappings,
+ selectPropertyMappings,
+ resolverMappings, forgeWithMappings,
+ isCtorBound: false, forger);
+
+ if (!conditional.Applicable || conditional.DidFail)
+ return (conditional, null);
+
+ string predicateArg;
+ if (conditional.Kind == ConditionalKind.Condition)
+ {
+ var srcPath = ResolveSourcePropertyPath(destProp, sourceType, propertyMappings);
+ var (srcExpr, _) = GenerateSourceExpressionWithNullInfo(sourceParam, srcPath, sourceType);
+ predicateArg = srcExpr;
+ }
+ else
+ {
+ predicateArg = sourceParam;
+ }
+
+ return (conditional, predicateArg);
+ }
+
+ ///
+ /// Wraps any skipNull/postConstruction entries appended to the given lists since the
+ /// recorded snapshot counts inside the conditional guard. Used by the Forge writers
+ /// when GeneratePropertyAssignment returns null (the property's emission was queued
+ /// instead of returned as a single expression) so the user's Condition/SkipWhen still
+ /// applies. Without this, conditional config is silently dropped for SkipNull /
+ /// post-construction collection / projection paths.
+ ///
+ private static void WrapQueuedEntriesWithConditionalGuard(
+ in ConditionalResolution conditional,
+ string predicateArg,
+ List<(string DestPropName, string SourceExpr, string LocalVarName, string? AssignExpr)> skipNullAssignments,
+ int skipNullSnapshot,
+ List<(string DestPropName, string Block)> postConstructionCollections,
+ int postCtorSnapshot)
+ {
+ if (!conditional.Applicable || conditional.DidFail) return;
+ var guard = BuildConditionalGuardExpression(in conditional, predicateArg);
+
+ // Wrap pre-existing post-construction entries first, before we append new ones
+ // from skipNull conversion (which already include the guard themselves).
+ for (int i = postCtorSnapshot; i < postConstructionCollections.Count; i++)
+ {
+ var (dest, block) = postConstructionCollections[i];
+ var indented = string.Join("\n", block.Split('\n').Select(l => l.Length == 0 ? l : " " + l));
+ postConstructionCollections[i] = (dest, $" if ({guard})\n {{\n{indented}\n }}");
+ }
+
+ for (int i = skipNullSnapshot; i < skipNullAssignments.Count; i++)
+ {
+ var (dest, src, localVar, assign) = skipNullAssignments[i];
+ // Convert skipNull entry into a post-construction guarded block and clear the entry.
+ var inner = $" if ({src} is {{ }} {localVar})\n {{\n result.{dest} = {assign ?? localVar};\n }}";
+ var block = $" if ({guard})\n {{\n{inner}\n }}";
+ postConstructionCollections.Add((dest, block));
+ }
+ if (skipNullAssignments.Count > skipNullSnapshot)
+ skipNullAssignments.RemoveRange(skipNullSnapshot, skipNullAssignments.Count - skipNullSnapshot);
+ }
+}
diff --git a/src/ForgeMap.Generator/ForgeCodeEmitter.Configuration.cs b/src/ForgeMap.Generator/ForgeCodeEmitter.Configuration.cs
index b08df7e..5c8f9d2 100644
--- a/src/ForgeMap.Generator/ForgeCodeEmitter.Configuration.cs
+++ b/src/ForgeMap.Generator/ForgeCodeEmitter.Configuration.cs
@@ -222,6 +222,60 @@ private Dictionary GetSelectPropertyMappings(IMethodSymbol metho
return result;
}
+ ///
+ /// Gets per-property Condition mappings from [ForgeProperty(Condition=...)] attributes.
+ /// Returns a dictionary mapping destination property name to the predicate method name.
+ /// Predicate is invoked with the source property value.
+ ///
+ private Dictionary GetConditionMappings(IMethodSymbol method)
+ {
+ var result = new Dictionary(StringComparer.Ordinal);
+ foreach (var attr in GetMethodAttributes(method, _forgePropertyAttributeSymbol))
+ {
+ if (attr.ConstructorArguments.Length < 2)
+ continue;
+ var destinationProperty = attr.ConstructorArguments[1].Value as string;
+ if (string.IsNullOrEmpty(destinationProperty))
+ continue;
+
+ foreach (var named in attr.NamedArguments)
+ {
+ if (named.Key == "Condition" && named.Value.Value is string predicateName && !string.IsNullOrEmpty(predicateName))
+ {
+ result[destinationProperty!] = predicateName;
+ }
+ }
+ }
+ return result;
+ }
+
+ ///
+ /// Gets per-property SkipWhen mappings from [ForgeProperty(SkipWhen=...)] attributes.
+ /// Returns a dictionary mapping destination property name to the predicate method name.
+ /// Predicate is invoked with the source object.
+ ///
+ private Dictionary GetSkipWhenMappings(IMethodSymbol method)
+ {
+ var result = new Dictionary(StringComparer.Ordinal);
+ foreach (var attr in GetMethodAttributes(method, _forgePropertyAttributeSymbol))
+ {
+ if (attr.ConstructorArguments.Length < 2)
+ continue;
+ var destinationProperty = attr.ConstructorArguments[1].Value as string;
+ if (string.IsNullOrEmpty(destinationProperty))
+ continue;
+
+ foreach (var named in attr.NamedArguments)
+ {
+ if (named.Key == "SkipWhen" && named.Value.Value is string predicateName && !string.IsNullOrEmpty(predicateName))
+ {
+ result[destinationProperty!] = predicateName;
+ }
+ }
+ }
+ return result;
+ }
+
///
/// Gets resolver mappings from [ForgeFrom] attributes.
/// Returns a dictionary mapping destination property name to resolver method name.
@@ -388,6 +442,8 @@ private void ResolveInheritedConfig(
Dictionary? existingTargetProperties,
Dictionary propertyConvertWithMappings,
Dictionary selectPropertyMappings,
+ Dictionary conditionMappings,
+ Dictionary skipWhenMappings,
HashSet visited)
{
var includeBaseForges = GetIncludeBaseForgeAttributes(method);
@@ -420,6 +476,8 @@ private void ResolveInheritedConfig(
var explicitForgeWithMappings = new HashSet(forgeWithMappings.Keys, StringComparer.Ordinal);
var explicitSelectPropertyMappings = new HashSet(selectPropertyMappings.Keys, StringComparer.Ordinal);
var explicitPropertyConvertWithMappings = new HashSet(propertyConvertWithMappings.Keys, StringComparer.Ordinal);
+ var explicitConditionMappings = new HashSet(conditionMappings.Keys, StringComparer.Ordinal);
+ var explicitSkipWhenMappings = new HashSet(skipWhenMappings.Keys, StringComparer.Ordinal);
foreach (var (baseSourceType, baseDestType, attrData) in includeBaseForges)
{
@@ -468,7 +526,9 @@ private void ResolveInheritedConfig(
var baseExistingTargetProperties = GetExistingTargetProperties(baseMethod);
var basePropertyConvertWithMappings = GetPropertyConvertWithMappings(baseMethod);
var baseSelectPropertyMappings = GetSelectPropertyMappings(baseMethod);
- ResolveInheritedConfig(baseMethod, forger, context, baseIgnored, basePropertyMappings, baseResolverMappings, baseForgeWithMappings, baseNullPropertyHandlingOverrides, existingTargetProperties != null ? baseExistingTargetProperties : null, basePropertyConvertWithMappings, baseSelectPropertyMappings, visited);
+ var baseConditionMappings = GetConditionMappings(baseMethod);
+ var baseSkipWhenMappings = GetSkipWhenMappings(baseMethod);
+ ResolveInheritedConfig(baseMethod, forger, context, baseIgnored, basePropertyMappings, baseResolverMappings, baseForgeWithMappings, baseNullPropertyHandlingOverrides, existingTargetProperties != null ? baseExistingTargetProperties : null, basePropertyConvertWithMappings, baseSelectPropertyMappings, baseConditionMappings, baseSkipWhenMappings, visited);
// Merge all base config into derived using first-wins semantics + FM0021 override reporting
var diagLocation = attrData.ApplicationSyntaxReference?.GetSyntax().GetLocation() ?? method.Locations.FirstOrDefault();
@@ -476,12 +536,14 @@ private void ResolveInheritedConfig(
bool IsExplicitlyConfigured(string propName) =>
explicitIgnored.Contains(propName) || explicitPropertyMappings.Contains(propName) ||
explicitResolverMappings.Contains(propName) || explicitForgeWithMappings.Contains(propName) ||
- explicitSelectPropertyMappings.Contains(propName) || explicitPropertyConvertWithMappings.Contains(propName);
+ explicitSelectPropertyMappings.Contains(propName) || explicitPropertyConvertWithMappings.Contains(propName) ||
+ explicitConditionMappings.Contains(propName) || explicitSkipWhenMappings.Contains(propName);
bool IsAlreadyConfigured(string propName) =>
ignoredProperties.Contains(propName) || propertyMappings.ContainsKey(propName) ||
resolverMappings.ContainsKey(propName) || forgeWithMappings.ContainsKey(propName) ||
- selectPropertyMappings.ContainsKey(propName) || propertyConvertWithMappings.ContainsKey(propName);
+ selectPropertyMappings.ContainsKey(propName) || propertyConvertWithMappings.ContainsKey(propName) ||
+ conditionMappings.ContainsKey(propName) || skipWhenMappings.ContainsKey(propName);
// Snapshot of dest props already configured by an EARLIER base in this includeBaseForges
// loop. Used by the SelectProperty merge below to detect cross-base leakage without
@@ -494,6 +556,8 @@ bool IsAlreadyConfigured(string propName) =>
preExistingConfigured.UnionWith(forgeWithMappings.Keys);
preExistingConfigured.UnionWith(selectPropertyMappings.Keys);
preExistingConfigured.UnionWith(propertyConvertWithMappings.Keys);
+ preExistingConfigured.UnionWith(conditionMappings.Keys);
+ preExistingConfigured.UnionWith(skipWhenMappings.Keys);
foreach (var propName in baseIgnored)
{
@@ -580,6 +644,41 @@ bool IsAlreadyConfigured(string propName) =>
if (!selectPropertyMappings.ContainsKey(kvp.Key))
selectPropertyMappings[kvp.Key] = kvp.Value;
}
+
+ // Merge base Condition mappings using first-wins semantics. Skip when the derived
+ // method explicitly configured this dest via any other kind ([ForgeFrom]/[ForgeWith]/
+ // [Ignore]/[ForgeProperty]/SelectProperty/ConvertWith/SkipWhen) — otherwise the
+ // inherited Condition would attach to a different mapping and trigger spurious
+ // FM0063/FM0060, or apply a guard the derived author didn't ask for. Also skip when an
+ // EARLIER base already configured this dest (any kind) so multi-[IncludeBaseForge]
+ // inheritance stays deterministic.
+ foreach (var kvp in baseConditionMappings)
+ {
+ if (IsExplicitlyConfigured(kvp.Key))
+ {
+ ReportDiagnosticIfNotSuppressed(context, DiagnosticDescriptors.IncludeBaseForgeOverridden, diagLocation, kvp.Key);
+ continue;
+ }
+ if (preExistingConfigured.Contains(kvp.Key))
+ continue;
+ if (!conditionMappings.ContainsKey(kvp.Key))
+ conditionMappings[kvp.Key] = kvp.Value;
+ }
+
+ // Merge base SkipWhen mappings using first-wins semantics. Same cross-kind override
+ // protection as Condition above.
+ foreach (var kvp in baseSkipWhenMappings)
+ {
+ if (IsExplicitlyConfigured(kvp.Key))
+ {
+ ReportDiagnosticIfNotSuppressed(context, DiagnosticDescriptors.IncludeBaseForgeOverridden, diagLocation, kvp.Key);
+ continue;
+ }
+ if (preExistingConfigured.Contains(kvp.Key))
+ continue;
+ if (!skipWhenMappings.ContainsKey(kvp.Key))
+ skipWhenMappings[kvp.Key] = kvp.Value;
+ }
}
}
@@ -601,8 +700,10 @@ private ResolvedMethodConfig ResolveMethodConfig(
var existingTargetProperties = GetExistingTargetProperties(method);
var propertyConvertWithMappings = GetPropertyConvertWithMappings(method);
var selectPropertyMappings = GetSelectPropertyMappings(method);
+ var conditionMappings = GetConditionMappings(method);
+ var skipWhenMappings = GetSkipWhenMappings(method);
- ResolveInheritedConfig(method, forger, context, ignoredProperties, propertyMappings, resolverMappings, forgeWithMappings, nullPropertyHandlingOverrides, existingTargetProperties, propertyConvertWithMappings, selectPropertyMappings, new HashSet());
+ ResolveInheritedConfig(method, forger, context, ignoredProperties, propertyMappings, resolverMappings, forgeWithMappings, nullPropertyHandlingOverrides, existingTargetProperties, propertyConvertWithMappings, selectPropertyMappings, conditionMappings, skipWhenMappings, new HashSet());
var beforeForgeHooks = GetBeforeForgeHooks(method)
.Select(h => ValidateBeforeForgeHook(h, sourceType, forger, context, method))
@@ -623,7 +724,9 @@ private ResolvedMethodConfig ResolveMethodConfig(
nullPropertyHandlingOverrides,
existingTargetProperties,
propertyConvertWithMappings,
- selectPropertyMappings);
+ selectPropertyMappings,
+ conditionMappings,
+ skipWhenMappings);
}
///
diff --git a/src/ForgeMap.Generator/ForgeCodeEmitter.ForgeIntoMethod.cs b/src/ForgeMap.Generator/ForgeCodeEmitter.ForgeIntoMethod.cs
index 98afc2a..7cf71bd 100644
--- a/src/ForgeMap.Generator/ForgeCodeEmitter.ForgeIntoMethod.cs
+++ b/src/ForgeMap.Generator/ForgeCodeEmitter.ForgeIntoMethod.cs
@@ -40,17 +40,54 @@ private string GenerateForgeIntoMethod(
// FM0075: SelectProperty is not yet supported on ForgeInto methods — warn so users
// know it will be silently ignored rather than producing surprising mapping behavior.
+ // FM0072 still wins over FM0075 + FM0063 when SelectProperty conflicts with [ForgeFrom]/[ForgeWith]
+ // on the same destination: emit FM0072 here so the conflict is surfaced even though the
+ // ForgeInto path does not call into the projection emitter.
if (cfg.SelectPropertyMappings.Count > 0)
{
var location = method.Locations.FirstOrDefault();
foreach (var destPropName in cfg.SelectPropertyMappings.Keys)
{
+ if (ignoredProperties.Contains(destPropName))
+ continue;
+ if (resolverMappings.ContainsKey(destPropName) || forgeWithMappings.ContainsKey(destPropName))
+ {
+ ReportDiagnosticIfNotSuppressed(context,
+ DiagnosticDescriptors.SelectPropertyConflictsWithForgeFromOrWith,
+ location, destPropName);
+ continue;
+ }
ReportDiagnosticIfNotSuppressed(context,
DiagnosticDescriptors.SelectPropertyNotSupportedOnForgeInto,
location, destPropName);
}
}
+ // FM0062 pre-pass for ForgeInto: Condition/SkipWhen on init-only or required destination
+ // properties is unsupported. The main loop filters init-only setters out, so without this
+ // pre-pass those configurations would be silently dropped instead of producing a diagnostic.
+ // [Ignore] still wins.
+ if (cfg.ConditionMappings.Count > 0 || cfg.SkipWhenMappings.Count > 0)
+ {
+ var location = method.Locations.FirstOrDefault();
+ var allDestProps = GetMappableProperties(destinationType).ToList();
+ foreach (var destPropName in cfg.ConditionMappings.Keys.Concat(cfg.SkipWhenMappings.Keys).Distinct())
+ {
+ if (ignoredProperties.Contains(destPropName))
+ continue;
+ var destProp = allDestProps.FirstOrDefault(p => p.Name == destPropName);
+ if (destProp == null)
+ continue;
+ var isInitOnly = destProp.SetMethod?.IsInitOnly == true;
+ if (isInitOnly || destProp.IsRequired)
+ {
+ ReportDiagnosticIfNotSuppressed(context,
+ DiagnosticDescriptors.ConditionalNotSupportedOnInitOrCtor,
+ location, destPropName);
+ }
+ }
+ }
+
// Method signature
var accessibility = GetAccessibilityKeyword(method.DeclaredAccessibility);
sb.AppendLine($" {accessibility} partial void {method.Name}({sourceType.ToDisplayString()} {sourceParam}, {destinationType.ToDisplayString()} {destParam})");
@@ -82,7 +119,9 @@ private string GenerateForgeIntoMethod(
sb, destProp, sourceParam, destParam, sourceType, sourceNamedType,
sourceProperties, ignoredProperties, existingTargetProperties,
propertyMappings, resolverMappings, forgeWithMappings,
- nullPropertyHandlingOverrides, forger, context, method);
+ nullPropertyHandlingOverrides, forger, context, method,
+ cfg.ConditionMappings, cfg.SkipWhenMappings, cfg.SelectPropertyMappings,
+ cfg.PropertyConvertWithMappings);
}
// [AfterForge] callbacks
@@ -118,12 +157,40 @@ private void GenerateForgeIntoPropertyAssignment(
Dictionary nullPropertyHandlingOverrides,
ForgerInfo forger,
SourceProductionContext context,
- IMethodSymbol method)
+ IMethodSymbol method,
+ Dictionary? cfgConditionMappings = null,
+ Dictionary? cfgSkipWhenMappings = null,
+ Dictionary? cfgSelectPropertyMappings = null,
+ Dictionary? cfgPropertyConvertWithMappings = null)
{
// Skip ignored properties
if (ignoredProperties.Contains(destProp.Name))
return;
+ // v1.7 Conditional (Condition / SkipWhen) for ForgeInto.
+ // Resolution validates and reports diagnostics FM0060–FM0063.
+ // FM0064 is deferred until the guarded assignment is actually emitted
+ // via FlushConditionalGuard / ReportConditionalAssignmentApplied.
+ // On Applicable+!Failed we capture per-property emission to a temporary
+ // StringBuilder and replay it inside an if-guard at every return point.
+ var conditionMappings = cfgConditionMappings ?? new Dictionary(StringComparer.Ordinal);
+ var skipWhenMappings = cfgSkipWhenMappings ?? new Dictionary(StringComparer.Ordinal);
+ var conditional = ResolveConditionalForProperty(
+ destProp, sourceType,
+ conditionMappings, skipWhenMappings,
+ propertyMappings,
+ cfgSelectPropertyMappings ?? new Dictionary(StringComparer.Ordinal),
+ resolverMappings, forgeWithMappings,
+ isCtorBound: false,
+ forger, context, method);
+
+ // On Failed, fall through and emit the unguarded assignment so output remains compilable.
+ StringBuilder workingSb = sb;
+ if (conditional.Applicable && !conditional.DidFail)
+ {
+ workingSb = new StringBuilder();
+ }
+
// Handle ExistingTarget properties — update nested object in place
if (existingTargetProperties.TryGetValue(destProp.Name, out var existingTargetCfg))
{
@@ -133,12 +200,29 @@ private void GenerateForgeIntoPropertyAssignment(
nullPropertyHandlingOverrides, forger, context, method);
if (etBlock != null)
{
- sb.AppendLine(etBlock);
+ workingSb.AppendLine(etBlock);
+ FlushConditionalGuard(sb, workingSb, conditional, sourceParam, destProp, propertyMappings, sourceNamedType, context, method);
return;
}
// null means scalar or Replace collection — fall through to normal assignment
}
+ // Per-property [ForgeProperty(ConvertWith = ...)] takes precedence over
+ // [ForgeFrom]/[ForgeWith] to match GeneratePropertyAssignment's order.
+ if (cfgPropertyConvertWithMappings != null
+ && cfgPropertyConvertWithMappings.TryGetValue(destProp.Name, out var convertWithInfo))
+ {
+ var convertExpr = GeneratePropertyConvertWithExpression(
+ destProp, convertWithInfo, sourceParam, sourceNamedType,
+ sourceProperties, propertyMappings, forger, context, method);
+ if (convertExpr != null)
+ {
+ workingSb.AppendLine($" {destParam}.{destProp.Name} = {convertExpr};");
+ }
+ FlushConditionalGuard(sb, workingSb, conditional, sourceParam, destProp, propertyMappings, sourceNamedType, context, method);
+ return;
+ }
+
// Check if this property has a resolver from [ForgeFrom]
if (resolverMappings.TryGetValue(destProp.Name, out var resolverMethodName))
{
@@ -172,6 +256,7 @@ private void GenerateForgeIntoPropertyAssignment(
DiagnosticDescriptors.ResolverMethodNotFound,
method.Locations.FirstOrDefault(),
resolverMethodName);
+ FlushConditionalGuard(sb, workingSb, conditional, sourceParam, destProp, propertyMappings, sourceNamedType, context, method);
return;
}
@@ -211,10 +296,12 @@ private void GenerateForgeIntoPropertyAssignment(
DiagnosticDescriptors.InvalidResolverSignature,
method.Locations.FirstOrDefault(),
resolverMethodName);
+ FlushConditionalGuard(sb, workingSb, conditional, sourceParam, destProp, propertyMappings, sourceNamedType, context, method);
return;
}
- sb.AppendLine($" {destParam}.{destProp.Name} = {resolverCall};");
+ workingSb.AppendLine($" {destParam}.{destProp.Name} = {resolverCall};");
+ FlushConditionalGuard(sb, workingSb, conditional, sourceParam, destProp, propertyMappings, sourceNamedType, context, method);
return;
}
@@ -246,9 +333,9 @@ private void GenerateForgeIntoPropertyAssignment(
if (sourcePropertyType.IsReferenceType)
{
var localVarName = $"__forgeWith_{destProp.Name}";
- sb.AppendLine($" if ({sourceExpr} is {{ }} {localVarName})");
- sb.AppendLine($" {destParam}.{destProp.Name} = {nestedForgeMethod.Name}({localVarName});");
- sb.AppendLine($" else");
+ workingSb.AppendLine($" if ({sourceExpr} is {{ }} {localVarName})");
+ workingSb.AppendLine($" {destParam}.{destProp.Name} = {nestedForgeMethod.Name}({localVarName});");
+ workingSb.AppendLine($" else");
string nullAssign;
if (destProp.Type.IsValueType)
{
@@ -268,12 +355,13 @@ private void GenerateForgeIntoPropertyAssignment(
nullAssign = "null!";
}
}
- sb.AppendLine($" {destParam}.{destProp.Name} = {nullAssign};");
+ workingSb.AppendLine($" {destParam}.{destProp.Name} = {nullAssign};");
}
else
{
- sb.AppendLine($" {destParam}.{destProp.Name} = {nestedForgeMethod.Name}({sourceExpr});");
+ workingSb.AppendLine($" {destParam}.{destProp.Name} = {nestedForgeMethod.Name}({sourceExpr});");
}
+ FlushConditionalGuard(sb, workingSb, conditional, sourceParam, destProp, propertyMappings, sourceNamedType, context, method);
return;
}
}
@@ -283,6 +371,7 @@ private void GenerateForgeIntoPropertyAssignment(
DiagnosticDescriptors.ResolverMethodNotFound,
method.Locations.FirstOrDefault(),
forgingMethodName);
+ FlushConditionalGuard(sb, workingSb, conditional, sourceParam, destProp, propertyMappings, sourceNamedType, context, method);
return;
}
@@ -299,13 +388,14 @@ private void GenerateForgeIntoPropertyAssignment(
var enumCast = TryGenerateCompatibleEnumCast(sourceLeafType, destProp.Type, sourceExpr2, isLifted: isLiftedValueType);
if (enumCast != null)
{
- sb.AppendLine($" {destParam}.{destProp.Name} = {enumCast};");
+ workingSb.AppendLine($" {destParam}.{destProp.Name} = {enumCast};");
+ FlushConditionalGuard(sb, workingSb, conditional, sourceParam, destProp, propertyMappings, sourceNamedType, context, method);
return;
}
}
if (isLiftedValueType && destProp.Type.IsValueType && GetNullableUnderlyingType(destProp.Type) == null)
{
- sb.AppendLine($" {destParam}.{destProp.Name} = ({destProp.Type.ToDisplayString()})({sourceExpr2})!;");
+ workingSb.AppendLine($" {destParam}.{destProp.Name} = ({destProp.Type.ToDisplayString()})({sourceExpr2})!;");
}
else if (sourceLeafType != null && IsNullableToNonNullableReferenceType(sourceLeafType, destProp.Type))
{
@@ -315,17 +405,17 @@ private void GenerateForgeIntoPropertyAssignment(
if (strategy == 1) // SkipNull
{
var localVar = GenerateSafeVariableName(destProp.Type) + "_" + destProp.Name;
- sb.AppendLine($" if ({sourceExpr2} is {{ }} {localVar})");
- sb.AppendLine($" {{");
- sb.AppendLine($" {destParam}.{destProp.Name} = {localVar};");
- sb.AppendLine($" }}");
+ workingSb.AppendLine($" if ({sourceExpr2} is {{ }} {localVar})");
+ workingSb.AppendLine($" {{");
+ workingSb.AppendLine($" {destParam}.{destProp.Name} = {localVar};");
+ workingSb.AppendLine($" }}");
}
else
{
var handledExpr = ApplyNullPropertyHandlingExpression(
sourceExpr2, destProp.Type, destProp.Name,
destProp.ContainingType.Name, strategy, method);
- sb.AppendLine($" {destParam}.{destProp.Name} = {handledExpr ?? $"{sourceExpr2}!"};");
+ workingSb.AppendLine($" {destParam}.{destProp.Name} = {handledExpr ?? $"{sourceExpr2}!"};");
}
}
else
@@ -345,7 +435,7 @@ private void GenerateForgeIntoPropertyAssignment(
destParam, forger, context, method, nullPropertyHandlingOverrides);
if (collBlock != null)
{
- sb.AppendLine(collBlock);
+ workingSb.AppendLine(collBlock);
}
else
{
@@ -353,7 +443,7 @@ private void GenerateForgeIntoPropertyAssignment(
destProp, sourceLeafType, sourceExpr2,
forger, context, method, nullPropertyHandlingOverrides);
if (autoWireResult != null)
- sb.AppendLine($" {destParam}.{destProp.Name} = {autoWireResult};");
+ workingSb.AppendLine($" {destParam}.{destProp.Name} = {autoWireResult};");
}
}
// If auto-wire didn't resolve, skip — property stays unmapped
@@ -371,16 +461,17 @@ private void GenerateForgeIntoPropertyAssignment(
if (strategy2 == 1) // SkipNull — wrap with if-guard
{
var localVar = "__strVal_" + SanitizeVarName(destProp.Name);
- sb.AppendLine($" if ({sourceExpr2} is {{ }} {localVar})");
- sb.AppendLine($" {{");
+ workingSb.AppendLine($" if ({sourceExpr2} is {{ }} {localVar})");
+ workingSb.AppendLine($" {{");
// Generate conversion using the non-nullable local var
var innerExpr = TryGenerateStringToEnumConversion(
sourceLeafType.WithNullableAnnotation(NullableAnnotation.NotAnnotated), destProp.Type, localVar,
destProp.Name, destProp.ContainingType.Name,
nullPropertyHandlingOverrides, context, method);
if (innerExpr != null)
- sb.AppendLine($" {destParam}.{destProp.Name} = {innerExpr};");
- sb.AppendLine($" }}");
+ workingSb.AppendLine($" {destParam}.{destProp.Name} = {innerExpr};");
+ workingSb.AppendLine($" }}");
+ FlushConditionalGuard(sb, workingSb, conditional, sourceParam, destProp, propertyMappings, sourceNamedType, context, method);
return;
}
}
@@ -391,7 +482,8 @@ private void GenerateForgeIntoPropertyAssignment(
nullPropertyHandlingOverrides, context, method);
if (enumConvExpr != null)
{
- sb.AppendLine($" {destParam}.{destProp.Name} = {enumConvExpr};");
+ workingSb.AppendLine($" {destParam}.{destProp.Name} = {enumConvExpr};");
+ FlushConditionalGuard(sb, workingSb, conditional, sourceParam, destProp, propertyMappings, sourceNamedType, context, method);
return;
}
}
@@ -408,31 +500,33 @@ private void GenerateForgeIntoPropertyAssignment(
if (strategy2 == 1) // SkipNull
{
var localVar = "__enumStr_" + destProp.Name;
- sb.AppendLine($" if ({enumStrExpr} is {{ }} {localVar})");
- sb.AppendLine($" {{");
- sb.AppendLine($" {destParam}.{destProp.Name} = {localVar};");
- sb.AppendLine($" }}");
+ workingSb.AppendLine($" if ({enumStrExpr} is {{ }} {localVar})");
+ workingSb.AppendLine($" {{");
+ workingSb.AppendLine($" {destParam}.{destProp.Name} = {localVar};");
+ workingSb.AppendLine($" }}");
}
else
{
var handledExpr = ApplyNullPropertyHandlingExpression(
enumStrExpr, destProp.Type, destProp.Name,
destProp.ContainingType.Name, strategy2, method);
- sb.AppendLine($" {destParam}.{destProp.Name} = {handledExpr ?? $"{enumStrExpr}!"};");
+ workingSb.AppendLine($" {destParam}.{destProp.Name} = {handledExpr ?? $"{enumStrExpr}!"};");
}
}
else
{
- sb.AppendLine($" {destParam}.{destProp.Name} = {enumStrExpr};");
+ workingSb.AppendLine($" {destParam}.{destProp.Name} = {enumStrExpr};");
}
+ FlushConditionalGuard(sb, workingSb, conditional, sourceParam, destProp, propertyMappings, sourceNamedType, context, method);
return;
}
// Add null-forgiving operator if we used null-conditional and dest is non-nullable
var nullForgiving = hasNullConditional2 && destProp.Type.NullableAnnotation != NullableAnnotation.Annotated ? "!" : "";
- sb.AppendLine($" {destParam}.{destProp.Name} = {sourceExpr2}{nullForgiving};");
+ workingSb.AppendLine($" {destParam}.{destProp.Name} = {sourceExpr2}{nullForgiving};");
}
}
+ FlushConditionalGuard(sb, workingSb, conditional, sourceParam, destProp, propertyMappings, sourceNamedType, context, method);
return;
}
@@ -448,15 +542,15 @@ private void GenerateForgeIntoPropertyAssignment(
var sourceExprVal = $"{sourceParam}.{sourceProp.Name}";
if (strategy == 1) // SkipNull
{
- sb.AppendLine($" if ({sourceExprVal} is {{ }} __val_{destProp.Name})");
- sb.AppendLine($" {{");
- sb.AppendLine($" {destParam}.{destProp.Name} = __val_{destProp.Name};");
- sb.AppendLine($" }}");
+ workingSb.AppendLine($" if ({sourceExprVal} is {{ }} __val_{destProp.Name})");
+ workingSb.AppendLine($" {{");
+ workingSb.AppendLine($" {destParam}.{destProp.Name} = __val_{destProp.Name};");
+ workingSb.AppendLine($" }}");
}
else
{
var handledExpr = ApplyNullableValueTypeCtorHandling(sourceExprVal, destProp.Type, destProp.Name, destProp.ContainingType.Name, strategy);
- sb.AppendLine($" {destParam}.{destProp.Name} = {handledExpr};");
+ workingSb.AppendLine($" {destParam}.{destProp.Name} = {handledExpr};");
}
}
else if (IsNullableToNonNullableReferenceType(sourceProp.Type, destProp.Type))
@@ -468,22 +562,22 @@ private void GenerateForgeIntoPropertyAssignment(
if (strategy == 1) // SkipNull
{
var localVar = GenerateSafeVariableName(destProp.Type) + "_" + destProp.Name;
- sb.AppendLine($" if ({sourceExprConv} is {{ }} {localVar})");
- sb.AppendLine($" {{");
- sb.AppendLine($" {destParam}.{destProp.Name} = {localVar};");
- sb.AppendLine($" }}");
+ workingSb.AppendLine($" if ({sourceExprConv} is {{ }} {localVar})");
+ workingSb.AppendLine($" {{");
+ workingSb.AppendLine($" {destParam}.{destProp.Name} = {localVar};");
+ workingSb.AppendLine($" }}");
}
else
{
var handledExpr = ApplyNullPropertyHandlingExpression(
sourceExprConv, destProp.Type, destProp.Name,
destProp.ContainingType.Name, strategy, method);
- sb.AppendLine($" {destParam}.{destProp.Name} = {handledExpr ?? $"{sourceExprConv}!"};");
+ workingSb.AppendLine($" {destParam}.{destProp.Name} = {handledExpr ?? $"{sourceExprConv}!"};");
}
}
else
{
- sb.AppendLine($" {destParam}.{destProp.Name} = {sourceParam}.{sourceProp.Name};");
+ workingSb.AppendLine($" {destParam}.{destProp.Name} = {sourceParam}.{sourceProp.Name};");
}
}
else if (sourceProp != null)
@@ -491,7 +585,7 @@ private void GenerateForgeIntoPropertyAssignment(
// Compatible enum cast: EnumA -> EnumB (different namespaces, same members)
var enumCastExpr = TryGenerateCompatibleEnumCast(sourceProp.Type, destProp.Type, $"{sourceParam}.{sourceProp.Name}");
if (enumCastExpr != null)
- sb.AppendLine($" {destParam}.{destProp.Name} = {enumCastExpr};");
+ workingSb.AppendLine($" {destParam}.{destProp.Name} = {enumCastExpr};");
// String→enum auto-conversion
else if (_config.StringToEnum != 2 && IsStringToEnumPair(sourceProp.Type, destProp.Type))
{
@@ -504,15 +598,15 @@ private void GenerateForgeIntoPropertyAssignment(
if (strategy == 1) // SkipNull — wrap with if-guard
{
var localVar = "__strVal_" + SanitizeVarName(destProp.Name);
- sb.AppendLine($" if ({srcExpr} is {{ }} {localVar})");
- sb.AppendLine($" {{");
+ workingSb.AppendLine($" if ({srcExpr} is {{ }} {localVar})");
+ workingSb.AppendLine($" {{");
var innerExpr = TryGenerateStringToEnumConversion(
sourceProp.Type.WithNullableAnnotation(NullableAnnotation.NotAnnotated), destProp.Type, localVar,
destProp.Name, destProp.ContainingType.Name,
nullPropertyHandlingOverrides, context, method);
if (innerExpr != null)
- sb.AppendLine($" {destParam}.{destProp.Name} = {innerExpr};");
- sb.AppendLine($" }}");
+ workingSb.AppendLine($" {destParam}.{destProp.Name} = {innerExpr};");
+ workingSb.AppendLine($" }}");
}
else
{
@@ -521,7 +615,7 @@ private void GenerateForgeIntoPropertyAssignment(
destProp.Name, destProp.ContainingType.Name,
nullPropertyHandlingOverrides, context, method);
if (enumConvExpr != null)
- sb.AppendLine($" {destParam}.{destProp.Name} = {enumConvExpr};");
+ workingSb.AppendLine($" {destParam}.{destProp.Name} = {enumConvExpr};");
}
}
else
@@ -531,7 +625,7 @@ private void GenerateForgeIntoPropertyAssignment(
destProp.Name, destProp.ContainingType.Name,
nullPropertyHandlingOverrides, context, method);
if (enumConvExpr != null)
- sb.AppendLine($" {destParam}.{destProp.Name} = {enumConvExpr};");
+ workingSb.AppendLine($" {destParam}.{destProp.Name} = {enumConvExpr};");
}
}
// Enum→string auto-conversion
@@ -546,22 +640,22 @@ private void GenerateForgeIntoPropertyAssignment(
if (strategy == 1) // SkipNull
{
var localVar = "__enumStr_" + destProp.Name;
- sb.AppendLine($" if ({enumStrExpr} is {{ }} {localVar})");
- sb.AppendLine($" {{");
- sb.AppendLine($" {destParam}.{destProp.Name} = {localVar};");
- sb.AppendLine($" }}");
+ workingSb.AppendLine($" if ({enumStrExpr} is {{ }} {localVar})");
+ workingSb.AppendLine($" {{");
+ workingSb.AppendLine($" {destParam}.{destProp.Name} = {localVar};");
+ workingSb.AppendLine($" }}");
}
else
{
var handledExpr = ApplyNullPropertyHandlingExpression(
enumStrExpr, destProp.Type, destProp.Name,
destProp.ContainingType.Name, strategy);
- sb.AppendLine($" {destParam}.{destProp.Name} = {handledExpr ?? $"{enumStrExpr}!"};");
+ workingSb.AppendLine($" {destParam}.{destProp.Name} = {handledExpr ?? $"{enumStrExpr}!"};");
}
}
else
{
- sb.AppendLine($" {destParam}.{destProp.Name} = {enumStrExpr};");
+ workingSb.AppendLine($" {destParam}.{destProp.Name} = {enumStrExpr};");
}
}
else if (_config.AutoWireNestedMappings)
@@ -572,7 +666,7 @@ private void GenerateForgeIntoPropertyAssignment(
destParam, forger, context, method, nullPropertyHandlingOverrides);
if (collBlock != null)
{
- sb.AppendLine(collBlock);
+ workingSb.AppendLine(collBlock);
}
else
{
@@ -580,10 +674,11 @@ private void GenerateForgeIntoPropertyAssignment(
destProp, sourceProp.Type, $"{sourceParam}.{sourceProp.Name}",
forger, context, method, nullPropertyHandlingOverrides);
if (autoWireResult != null)
- sb.AppendLine($" {destParam}.{destProp.Name} = {autoWireResult};");
+ workingSb.AppendLine($" {destParam}.{destProp.Name} = {autoWireResult};");
}
}
}
+ FlushConditionalGuard(sb, workingSb, conditional, sourceParam, destProp, propertyMappings, sourceNamedType, context, method);
}
///
@@ -1032,6 +1127,48 @@ private void GenerateForgeIntoPropertyAssignment(
return null;
}
+ private void FlushConditionalGuard(
+ StringBuilder destSb,
+ StringBuilder workingSb,
+ in ConditionalResolution conditional,
+ string sourceParam,
+ IPropertySymbol destProp,
+ Dictionary propertyMappings,
+ INamedTypeSymbol sourceNamedType,
+ SourceProductionContext context,
+ IMethodSymbol method)
+ {
+ if (!conditional.Applicable || conditional.DidFail || ReferenceEquals(destSb, workingSb))
+ return;
+
+ string predicateArg;
+ if (conditional.Kind == ConditionalKind.Condition)
+ {
+ var srcPath = ResolveSourcePropertyPath(destProp, sourceNamedType, propertyMappings);
+ var (srcExpr, _) = GenerateSourceExpressionWithNullInfo(sourceParam, srcPath, sourceNamedType);
+ predicateArg = srcExpr;
+ }
+ else
+ {
+ predicateArg = sourceParam;
+ }
+
+ var guard = BuildConditionalGuardExpression(in conditional, predicateArg);
+ var inner = workingSb.ToString();
+ if (inner.Length == 0) return;
+
+ destSb.Append(" if (").Append(guard).AppendLine(")");
+ destSb.AppendLine(" {");
+ foreach (var line in inner.Split('\n'))
+ {
+ if (line.Length == 0) continue;
+ destSb.Append(" ").AppendLine(line.TrimEnd('\r'));
+ }
+ destSb.AppendLine(" }");
+ workingSb.Clear();
+ ReportConditionalAssignmentApplied(context, method, destProp, in conditional);
+ }
+
///
/// Generates a collection forging method (List<T>, T[], IEnumerable<T>, etc.).
///
diff --git a/src/ForgeMap.Generator/ForgeCodeEmitter.ForgeMethod.cs b/src/ForgeMap.Generator/ForgeCodeEmitter.ForgeMethod.cs
index 7e23d0f..40f257d 100644
--- a/src/ForgeMap.Generator/ForgeCodeEmitter.ForgeMethod.cs
+++ b/src/ForgeMap.Generator/ForgeCodeEmitter.ForgeMethod.cs
@@ -176,21 +176,21 @@ private string GenerateForgeMethod(
sb, destinationType, sourceType, sourceParam, sourceProperties, destProperties,
ctorParamMappings, ctorCoveredDestProps, ignoredProperties, propertyMappings,
resolverMappings, forgeWithMappings, nullPropertyHandlingOverrides,
- afterForgeHooks, forger, context, method, propertyConvertWithMappings, selectPropertyMappings);
+ afterForgeHooks, forger, context, method, propertyConvertWithMappings, selectPropertyMappings, cfg.ConditionMappings, cfg.SkipWhenMappings);
}
else if (hasAfterForge)
{
GenerateObjInitWithAfterForge(
sb, destinationType, sourceType, sourceParam, sourceProperties, destProperties,
ignoredProperties, propertyMappings, resolverMappings, forgeWithMappings,
- nullPropertyHandlingOverrides, afterForgeHooks, forger, context, method, propertyConvertWithMappings, selectPropertyMappings);
+ nullPropertyHandlingOverrides, afterForgeHooks, forger, context, method, propertyConvertWithMappings, selectPropertyMappings, cfg.ConditionMappings, cfg.SkipWhenMappings);
}
else
{
GenerateSimpleObjectInit(
sb, destinationType, sourceType, sourceParam, sourceProperties, destProperties,
ignoredProperties, propertyMappings, resolverMappings, forgeWithMappings,
- nullPropertyHandlingOverrides, forger, context, method, propertyConvertWithMappings, selectPropertyMappings);
+ nullPropertyHandlingOverrides, forger, context, method, propertyConvertWithMappings, selectPropertyMappings, cfg.ConditionMappings, cfg.SkipWhenMappings);
}
sb.AppendLine(" }");
@@ -303,7 +303,9 @@ private void GenerateCtorPlusInitializer(
SourceProductionContext context,
IMethodSymbol method,
Dictionary? propertyConvertWithMappings = null,
- Dictionary? selectPropertyMappings = null)
+ Dictionary? selectPropertyMappings = null,
+ Dictionary? conditionMappings = null,
+ Dictionary? skipWhenMappings = null)
{
// Constructor mapping: generate new Dest(param1: expr1, param2: expr2) { Prop = value, ... }
// Using object initializer syntax so init-only properties work too
@@ -315,6 +317,27 @@ private void GenerateCtorPlusInitializer(
sb.AppendLine(mapping.PreConstructionBlock);
}
+ // FM0062 pre-pass: any [ForgeProperty(Condition=...)] / SkipWhen targeting a
+ // ctor-bound destination property must be flagged before we filter those props
+ // out of the initializer loop.
+ if (conditionMappings != null || skipWhenMappings != null)
+ {
+ foreach (var destProp in destProperties)
+ {
+ if (!ctorCoveredDestProps.Contains(destProp.Name))
+ continue;
+ if (ignoredProperties.Contains(destProp.Name))
+ continue;
+ if ((conditionMappings != null && conditionMappings.ContainsKey(destProp.Name))
+ || (skipWhenMappings != null && skipWhenMappings.ContainsKey(destProp.Name)))
+ {
+ ReportDiagnosticIfNotSuppressed(context,
+ DiagnosticDescriptors.ConditionalNotSupportedOnInitOrCtor,
+ method.Locations.FirstOrDefault(), destProp.Name);
+ }
+ }
+ }
+
// Collect remaining property assignments for object initializer
var remainingDestProps = destProperties
.Where(p => p.SetMethod != null && p.SetMethod.DeclaredAccessibility >= Accessibility.Internal && !ctorCoveredDestProps.Contains(p.Name))
@@ -329,14 +352,48 @@ private void GenerateCtorPlusInitializer(
if (ignoredProperties.Contains(destProp.Name))
continue;
+ var skipNullSnapshot = skipNullAssignmentsForCtor.Count;
+ var postCtorSnapshot = postConstructionCollectionsForCtor.Count;
+
var assignment = GeneratePropertyAssignment(
destProp, sourceParam, sourceType, sourceProperties,
propertyMappings, resolverMappings, forgeWithMappings, ignoredProperties, forger, context, method,
nullPropertyHandlingOverrides, skipNullAssignmentsForCtor,
- postConstructionCollectionsForCtor, preConstructionBlocksForCtor, propertyConvertWithMappings, selectPropertyMappings);
+ postConstructionCollectionsForCtor, preConstructionBlocksForCtor, propertyConvertWithMappings, selectPropertyMappings, conditionMappings, skipWhenMappings, isCtorBound: false);
+
+ var (conditional, predicateArg) = ResolveConditionalAndPredicateArg(
+ destProp, sourceType, sourceParam,
+ conditionMappings ?? new Dictionary(StringComparer.Ordinal),
+ skipWhenMappings ?? new Dictionary(StringComparer.Ordinal),
+ propertyMappings,
+ selectPropertyMappings ?? new Dictionary(StringComparer.Ordinal),
+ resolverMappings, forgeWithMappings, forger);
if (assignment != null)
- initAssignments.Add((destProp.Name, assignment));
+ {
+ if (predicateArg != null)
+ {
+ var guard = BuildConditionalGuardExpression(in conditional, predicateArg);
+ var block = $" if ({guard})\n {{\n result.{destProp.Name} = {assignment};\n }}";
+ postConstructionCollectionsForCtor.Add((destProp.Name, block));
+ ReportConditionalAssignmentApplied(context, method, destProp, in conditional);
+ }
+ else
+ {
+ initAssignments.Add((destProp.Name, assignment));
+ }
+ }
+ else if (predicateArg != null)
+ {
+ var hadEntries = skipNullAssignmentsForCtor.Count > skipNullSnapshot
+ || postConstructionCollectionsForCtor.Count > postCtorSnapshot;
+ WrapQueuedEntriesWithConditionalGuard(
+ in conditional, predicateArg,
+ skipNullAssignmentsForCtor, skipNullSnapshot,
+ postConstructionCollectionsForCtor, postCtorSnapshot);
+ if (hadEntries)
+ ReportConditionalAssignmentApplied(context, method, destProp, in conditional);
+ }
}
// Emit pre-construction blocks for init-only collection properties
@@ -411,7 +468,9 @@ private void GenerateObjInitWithAfterForge(
SourceProductionContext context,
IMethodSymbol method,
Dictionary? propertyConvertWithMappings = null,
- Dictionary? selectPropertyMappings = null)
+ Dictionary? selectPropertyMappings = null,
+ Dictionary? conditionMappings = null,
+ Dictionary? skipWhenMappings = null)
{
// When AfterForge hooks exist, we need a variable to pass to the hooks
var skipNullAssignmentsAfterForge = new List<(string DestPropName, string SourceExpr, string LocalVarName, string? AssignExpr)>();
@@ -421,14 +480,48 @@ private void GenerateObjInitWithAfterForge(
var afterForgeAssignments = new List<(string Name, string Expr)>();
foreach (var destProp in destProperties.Where(p => p.SetMethod != null && p.SetMethod.DeclaredAccessibility >= Accessibility.Internal))
{
+ var skipNullSnapshot = skipNullAssignmentsAfterForge.Count;
+ var postCtorSnapshot = postConstructionCollectionsAfterForge.Count;
+
var assignment = GeneratePropertyAssignment(
destProp, sourceParam, sourceType, sourceProperties,
propertyMappings, resolverMappings, forgeWithMappings, ignoredProperties, forger, context, method,
nullPropertyHandlingOverrides, skipNullAssignmentsAfterForge,
- postConstructionCollectionsAfterForge, preConstructionBlocksAfterForge, propertyConvertWithMappings, selectPropertyMappings);
+ postConstructionCollectionsAfterForge, preConstructionBlocksAfterForge, propertyConvertWithMappings, selectPropertyMappings, conditionMappings, skipWhenMappings, isCtorBound: false);
+
+ var (conditional, predicateArg) = ResolveConditionalAndPredicateArg(
+ destProp, sourceType, sourceParam,
+ conditionMappings ?? new Dictionary(StringComparer.Ordinal),
+ skipWhenMappings ?? new Dictionary(StringComparer.Ordinal),
+ propertyMappings,
+ selectPropertyMappings ?? new Dictionary(StringComparer.Ordinal),
+ resolverMappings, forgeWithMappings, forger);
if (assignment != null)
- afterForgeAssignments.Add((destProp.Name, assignment));
+ {
+ if (predicateArg != null)
+ {
+ var guard = BuildConditionalGuardExpression(in conditional, predicateArg);
+ var block = $" if ({guard})\n {{\n result.{destProp.Name} = {assignment};\n }}";
+ postConstructionCollectionsAfterForge.Add((destProp.Name, block));
+ ReportConditionalAssignmentApplied(context, method, destProp, in conditional);
+ }
+ else
+ {
+ afterForgeAssignments.Add((destProp.Name, assignment));
+ }
+ }
+ else if (predicateArg != null)
+ {
+ var hadEntries = skipNullAssignmentsAfterForge.Count > skipNullSnapshot
+ || postConstructionCollectionsAfterForge.Count > postCtorSnapshot;
+ WrapQueuedEntriesWithConditionalGuard(
+ in conditional, predicateArg,
+ skipNullAssignmentsAfterForge, skipNullSnapshot,
+ postConstructionCollectionsAfterForge, postCtorSnapshot);
+ if (hadEntries)
+ ReportConditionalAssignmentApplied(context, method, destProp, in conditional);
+ }
}
// Emit pre-construction blocks for init-only collection properties
@@ -484,7 +577,9 @@ private void GenerateSimpleObjectInit(
SourceProductionContext context,
IMethodSymbol method,
Dictionary? propertyConvertWithMappings = null,
- Dictionary? selectPropertyMappings = null)
+ Dictionary? selectPropertyMappings = null,
+ Dictionary? conditionMappings = null,
+ Dictionary? skipWhenMappings = null)
{
// Object initializer pattern
var skipNullAssignmentsPlain = new List<(string DestPropName, string SourceExpr, string LocalVarName, string? AssignExpr)>();
@@ -494,14 +589,48 @@ private void GenerateSimpleObjectInit(
foreach (var destProp in destProperties.Where(p => p.SetMethod != null && p.SetMethod.DeclaredAccessibility >= Accessibility.Internal))
{
+ var skipNullSnapshot = skipNullAssignmentsPlain.Count;
+ var postCtorSnapshot = postConstructionCollectionsPlain.Count;
+
var assignment = GeneratePropertyAssignment(
destProp, sourceParam, sourceType, sourceProperties,
propertyMappings, resolverMappings, forgeWithMappings, ignoredProperties, forger, context, method,
nullPropertyHandlingOverrides, skipNullAssignmentsPlain,
- postConstructionCollectionsPlain, preConstructionBlocksPlain, propertyConvertWithMappings, selectPropertyMappings);
+ postConstructionCollectionsPlain, preConstructionBlocksPlain, propertyConvertWithMappings, selectPropertyMappings, conditionMappings, skipWhenMappings, isCtorBound: false);
+
+ var (conditional, predicateArg) = ResolveConditionalAndPredicateArg(
+ destProp, sourceType, sourceParam,
+ conditionMappings ?? new Dictionary(StringComparer.Ordinal),
+ skipWhenMappings ?? new Dictionary(StringComparer.Ordinal),
+ propertyMappings,
+ selectPropertyMappings ?? new Dictionary(StringComparer.Ordinal),
+ resolverMappings, forgeWithMappings, forger);
if (assignment != null)
- plainAssignments.Add((destProp.Name, assignment));
+ {
+ if (predicateArg != null)
+ {
+ var guard = BuildConditionalGuardExpression(in conditional, predicateArg);
+ var block = $" if ({guard})\n {{\n result.{destProp.Name} = {assignment};\n }}";
+ postConstructionCollectionsPlain.Add((destProp.Name, block));
+ ReportConditionalAssignmentApplied(context, method, destProp, in conditional);
+ }
+ else
+ {
+ plainAssignments.Add((destProp.Name, assignment));
+ }
+ }
+ else if (predicateArg != null)
+ {
+ var hadEntries = skipNullAssignmentsPlain.Count > skipNullSnapshot
+ || postConstructionCollectionsPlain.Count > postCtorSnapshot;
+ WrapQueuedEntriesWithConditionalGuard(
+ in conditional, predicateArg,
+ skipNullAssignmentsPlain, skipNullSnapshot,
+ postConstructionCollectionsPlain, postCtorSnapshot);
+ if (hadEntries)
+ ReportConditionalAssignmentApplied(context, method, destProp, in conditional);
+ }
}
var needsResultVar = skipNullAssignmentsPlain.Count > 0 ||
diff --git a/src/ForgeMap.Generator/ForgeCodeEmitter.PropertyAssignment.cs b/src/ForgeMap.Generator/ForgeCodeEmitter.PropertyAssignment.cs
index 1beb8aa..563c308 100644
--- a/src/ForgeMap.Generator/ForgeCodeEmitter.PropertyAssignment.cs
+++ b/src/ForgeMap.Generator/ForgeCodeEmitter.PropertyAssignment.cs
@@ -31,12 +31,34 @@ internal sealed partial class ForgeCodeEmitter
List<(string DestPropName, string Block)>? postConstructionCollections = null,
List? preConstructionBlocks = null,
Dictionary? propertyConvertWithMappings = null,
- Dictionary? selectPropertyMappings = null)
+ Dictionary? selectPropertyMappings = null,
+ Dictionary? conditionMappings = null,
+ Dictionary? skipWhenMappings = null,
+ bool isCtorBound = false)
{
// Skip ignored properties
if (ignoredProperties.Contains(destProp.Name))
return null;
+ // v1.7 Conditional (Condition / SkipWhen) — validate and report diagnostics
+ // before the assignment is built. The actual `if`-guard wrapping is applied
+ // by the caller (Forge writers / ForgeInto) because object initializers
+ // cannot contain `if`, and the writer already moves multi-statement
+ // emissions out of the initializer via postConstructionCollections.
+ if ((conditionMappings != null && conditionMappings.ContainsKey(destProp.Name))
+ || (skipWhenMappings != null && skipWhenMappings.ContainsKey(destProp.Name)))
+ {
+ ResolveConditionalForProperty(
+ destProp, sourceType,
+ conditionMappings ?? new Dictionary(StringComparer.Ordinal),
+ skipWhenMappings ?? new Dictionary(StringComparer.Ordinal),
+ propertyMappings,
+ selectPropertyMappings ?? new Dictionary(StringComparer.Ordinal),
+ resolverMappings, forgeWithMappings,
+ isCtorBound,
+ forger, context, method);
+ }
+
// v1.7 SelectProperty projection has highest precedence among per-property directives.
if (selectPropertyMappings != null && selectPropertyMappings.ContainsKey(destProp.Name))
{
diff --git a/src/ForgeMap.Generator/ForgerConfig.cs b/src/ForgeMap.Generator/ForgerConfig.cs
index e71d204..dd5ab75 100644
--- a/src/ForgeMap.Generator/ForgerConfig.cs
+++ b/src/ForgeMap.Generator/ForgerConfig.cs
@@ -71,7 +71,9 @@ public ResolvedMethodConfig(
Dictionary nullPropertyHandlingOverrides,
Dictionary existingTargetProperties,
Dictionary? propertyConvertWithMappings = null,
- Dictionary? selectPropertyMappings = null)
+ Dictionary? selectPropertyMappings = null,
+ Dictionary? conditionMappings = null,
+ Dictionary? skipWhenMappings = null)
{
IgnoredProperties = ignoredProperties;
PropertyMappings = propertyMappings;
@@ -83,6 +85,8 @@ public ResolvedMethodConfig(
ExistingTargetProperties = existingTargetProperties;
PropertyConvertWithMappings = propertyConvertWithMappings ?? new Dictionary(StringComparer.Ordinal);
SelectPropertyMappings = selectPropertyMappings ?? new Dictionary(StringComparer.Ordinal);
+ ConditionMappings = conditionMappings ?? new Dictionary(StringComparer.Ordinal);
+ SkipWhenMappings = skipWhenMappings ?? new Dictionary(StringComparer.Ordinal);
}
public HashSet IgnoredProperties { get; }
@@ -99,4 +103,8 @@ public ResolvedMethodConfig(
public Dictionary PropertyConvertWithMappings { get; }
/// Per-property SelectProperty (v1.7) mappings. Key = dest property name, Value = element member name to project.
public Dictionary SelectPropertyMappings { get; }
+ /// Per-property Condition (v1.7) predicate mappings. Key = dest property name, Value = predicate method name (called with source-property value).
+ public Dictionary ConditionMappings { get; }
+ /// Per-property SkipWhen (v1.7) predicate mappings. Key = dest property name, Value = predicate method name (called with source object).
+ public Dictionary SkipWhenMappings { get; }
}
diff --git a/tests/ForgeMap.Tests/ConditionalAssignmentGeneratorTests.cs b/tests/ForgeMap.Tests/ConditionalAssignmentGeneratorTests.cs
new file mode 100644
index 0000000..5352c48
--- /dev/null
+++ b/tests/ForgeMap.Tests/ConditionalAssignmentGeneratorTests.cs
@@ -0,0 +1,322 @@
+using Microsoft.CodeAnalysis;
+using Xunit;
+using static ForgeMap.Tests.TestHelper;
+
+namespace ForgeMap.Tests;
+
+public class ConditionalAssignmentGeneratorTests
+{
+ [Fact]
+ public void Condition_OnForgeInto_EmitsGuardedAssignment()
+ {
+ var source = @"
+using ForgeMap;
+
+public class Src { public string? Name { get; set; } }
+public class Dst { public string? Name { get; set; } }
+
+[ForgeMap]
+public partial class M
+{
+ [ForgeProperty(nameof(Src.Name), nameof(Dst.Name), Condition = nameof(IsNotNull))]
+ public partial void ForgeInto(Src source, [UseExistingValue] Dst destination);
+
+ private static bool IsNotNull(string? v) => v is not null;
+}";
+ var (diagnostics, trees) = RunGenerator(source);
+ Assert.Empty(diagnostics.Where(d => d.Severity == DiagnosticSeverity.Error));
+ var generated = string.Join("\n", trees.Select(t => t.GetText().ToString()));
+ Assert.Contains("if (IsNotNull(source.Name))", generated);
+ Assert.Contains("destination.Name = source.Name", generated);
+ }
+
+ [Fact]
+ public void SkipWhen_OnForgeInto_EmitsNegatedGuard()
+ {
+ var source = @"
+using ForgeMap;
+
+public class Src { public int Id { get; set; } }
+public class Dst { public int Id { get; set; } }
+
+[ForgeMap]
+public partial class M
+{
+ [ForgeProperty(nameof(Src.Id), nameof(Dst.Id), SkipWhen = nameof(IdIsZero))]
+ public partial void ForgeInto(Src source, [UseExistingValue] Dst destination);
+
+ private static bool IdIsZero(Src s) => s.Id == 0;
+}";
+ var (diagnostics, trees) = RunGenerator(source);
+ Assert.Empty(diagnostics.Where(d => d.Severity == DiagnosticSeverity.Error));
+ var generated = string.Join("\n", trees.Select(t => t.GetText().ToString()));
+ Assert.Contains("if (!IdIsZero(source))", generated);
+ Assert.Contains("destination.Id = source.Id", generated);
+ }
+
+ [Fact]
+ public void Condition_OnForge_EmitsPostConstructionGuard()
+ {
+ var source = @"
+using ForgeMap;
+
+public class Src { public string? Protocol { get; set; } }
+public class Dst { public string Protocol { get; set; } = ""default""; }
+
+[ForgeMap]
+public partial class M
+{
+ [ForgeProperty(nameof(Src.Protocol), nameof(Dst.Protocol), Condition = nameof(IsNotNull))]
+ public partial Dst Forge(Src source);
+
+ private static bool IsNotNull(string? v) => v is not null;
+}";
+ var (diagnostics, trees) = RunGenerator(source);
+ Assert.Empty(diagnostics.Where(d => d.Severity == DiagnosticSeverity.Error));
+ var generated = string.Join("\n", trees.Select(t => t.GetText().ToString()));
+ Assert.Contains("var result = new", generated);
+ Assert.Contains("if (IsNotNull(source.Protocol))", generated);
+ Assert.Contains("result.Protocol = source.Protocol", generated);
+ }
+
+ [Fact]
+ public void Condition_BothConditionAndSkipWhen_EmitsFM0060()
+ {
+ var source = @"
+using ForgeMap;
+
+public class Src { public string? Name { get; set; } }
+public class Dst { public string? Name { get; set; } }
+
+[ForgeMap]
+public partial class M
+{
+ [ForgeProperty(nameof(Src.Name), nameof(Dst.Name),
+ Condition = nameof(IsNotNull), SkipWhen = nameof(IsEmptySrc))]
+ public partial void ForgeInto(Src source, [UseExistingValue] Dst destination);
+
+ private static bool IsNotNull(string? v) => v is not null;
+ private static bool IsEmptySrc(Src s) => s.Name is null;
+}";
+ var (diagnostics, _) = RunGenerator(source);
+ Assert.Contains(diagnostics, d => d.Id == "FM0060");
+ }
+
+ [Fact]
+ public void Condition_PredicateNotFound_EmitsFM0061()
+ {
+ var source = @"
+using ForgeMap;
+
+public class Src { public string? Name { get; set; } }
+public class Dst { public string? Name { get; set; } }
+
+[ForgeMap]
+public partial class M
+{
+ [ForgeProperty(nameof(Src.Name), nameof(Dst.Name), Condition = ""DoesNotExist"")]
+ public partial void ForgeInto(Src source, [UseExistingValue] Dst destination);
+}";
+ var (diagnostics, _) = RunGenerator(source);
+ Assert.Contains(diagnostics, d => d.Id == "FM0061");
+ }
+
+ [Fact]
+ public void Condition_PredicateWrongReturnType_EmitsFM0061()
+ {
+ var source = @"
+using ForgeMap;
+
+public class Src { public string? Name { get; set; } }
+public class Dst { public string? Name { get; set; } }
+
+[ForgeMap]
+public partial class M
+{
+ [ForgeProperty(nameof(Src.Name), nameof(Dst.Name), Condition = nameof(NotABool))]
+ public partial void ForgeInto(Src source, [UseExistingValue] Dst destination);
+
+ private static int NotABool(string? v) => 0;
+}";
+ var (diagnostics, _) = RunGenerator(source);
+ Assert.Contains(diagnostics, d => d.Id == "FM0061");
+ }
+
+ [Fact]
+ public void Condition_OnInitOnlyProperty_EmitsFM0062()
+ {
+ var source = @"
+using ForgeMap;
+
+public class Src { public string Name { get; set; } = """"; }
+public class Dst { public string Name { get; init; } = """"; }
+
+[ForgeMap]
+public partial class M
+{
+ [ForgeProperty(nameof(Src.Name), nameof(Dst.Name), Condition = nameof(IsNotEmpty))]
+ public partial Dst Forge(Src source);
+
+ private static bool IsNotEmpty(string v) => v.Length > 0;
+}";
+ var (diagnostics, _) = RunGenerator(source);
+ Assert.Contains(diagnostics, d => d.Id == "FM0062");
+ }
+
+ [Fact]
+ public void Condition_OnConstructorParameter_EmitsFM0062()
+ {
+ var source = @"
+using ForgeMap;
+
+public class Src { public string Name { get; set; } = """"; }
+public class Dst
+{
+ public Dst(string name) { Name = name; }
+ public string Name { get; }
+}
+
+[ForgeMap]
+public partial class M
+{
+ [ForgeProperty(nameof(Src.Name), nameof(Dst.Name), Condition = nameof(IsNotEmpty))]
+ public partial Dst Forge(Src source);
+
+ private static bool IsNotEmpty(string v) => v.Length > 0;
+}";
+ var (diagnostics, _) = RunGenerator(source);
+ Assert.Contains(diagnostics, d => d.Id == "FM0062");
+ }
+
+ [Fact]
+ public void Condition_WithForgeFrom_EmitsFM0063()
+ {
+ var source = @"
+using ForgeMap;
+
+public class Src { public string? Name { get; set; } }
+public class Dst { public string? Name { get; set; } }
+
+[ForgeMap]
+public partial class M
+{
+ [ForgeProperty(nameof(Src.Name), nameof(Dst.Name), Condition = nameof(IsNotNull))]
+ [ForgeFrom(nameof(Dst.Name), nameof(Resolve))]
+ public partial void ForgeInto(Src source, [UseExistingValue] Dst destination);
+
+ private static bool IsNotNull(string? v) => v is not null;
+ private static string? Resolve(Src s) => s.Name;
+}";
+ var (diagnostics, _) = RunGenerator(source);
+ Assert.Contains(diagnostics, d => d.Id == "FM0063");
+ }
+
+ [Fact]
+ public void Condition_WithSelectProperty_FM0063_SuppressedByFM0072()
+ {
+ var source = @"
+using System.Collections.Generic;
+using ForgeMap;
+
+public class Tag { public string Name { get; set; } = """"; }
+public class Src { public List? Tags { get; set; } }
+public class Dst { public List? Tags { get; set; } }
+
+[ForgeMap]
+public partial class M
+{
+ [ForgeProperty(nameof(Src.Tags), nameof(Dst.Tags),
+ SelectProperty = nameof(Tag.Name),
+ Condition = nameof(HasItems))]
+ [ForgeFrom(nameof(Dst.Tags), nameof(Resolve))]
+ public partial void ForgeInto(Src source, [UseExistingValue] Dst destination);
+
+ private static bool HasItems(List? t) => t is { Count: > 0 };
+ private static List Resolve(Src s) => new();
+}";
+ var (diagnostics, _) = RunGenerator(source);
+ Assert.Contains(diagnostics, d => d.Id == "FM0072");
+ Assert.DoesNotContain(diagnostics, d => d.Id == "FM0063");
+ }
+
+ [Fact]
+ public void Condition_ComposedWithConvertWith_GuardWrapsConverter()
+ {
+ var source = @"
+using ForgeMap;
+
+public class Src { public string? Raw { get; set; } }
+public class Dst { public int Cooked { get; set; } }
+
+[ForgeMap]
+public partial class M
+{
+ [ForgeProperty(nameof(Src.Raw), nameof(Dst.Cooked),
+ Condition = nameof(IsNumber),
+ ConvertWith = nameof(Parse))]
+ public partial void ForgeInto(Src source, [UseExistingValue] Dst destination);
+
+ private static bool IsNumber(string? v) => int.TryParse(v, out _);
+ private static int Parse(string s) => int.Parse(s);
+}";
+ var (diagnostics, trees) = RunGenerator(source);
+ Assert.Empty(diagnostics.Where(d => d.Severity == DiagnosticSeverity.Error));
+ var generated = string.Join("\n", trees.Select(t => t.GetText().ToString()));
+ Assert.Contains("if (IsNumber(source.Raw))", generated);
+ Assert.Contains("Parse(", generated);
+ }
+
+ [Fact]
+ public void SkipWhen_OnIgnoredProperty_NoDiagnostic_NoEmit()
+ {
+ var source = @"
+using ForgeMap;
+
+public class Src { public int Id { get; set; } }
+public class Dst { public int Id { get; set; } }
+
+[ForgeMap]
+public partial class M
+{
+ [Ignore(nameof(Dst.Id))]
+ [ForgeProperty(nameof(Src.Id), nameof(Dst.Id), SkipWhen = nameof(IdIsZero))]
+ public partial void ForgeInto(Src source, [UseExistingValue] Dst destination);
+
+ private static bool IdIsZero(Src s) => s.Id == 0;
+}";
+ var (diagnostics, trees) = RunGenerator(source);
+ Assert.Empty(diagnostics.Where(d => d.Severity == DiagnosticSeverity.Error));
+ var generated = string.Join("\n", trees.Select(t => t.GetText().ToString()));
+ Assert.DoesNotContain("IdIsZero", generated);
+ Assert.DoesNotContain("destination.Id = source.Id", generated);
+ }
+
+ [Fact]
+ public void Condition_DoesNotPropagateThroughReverseForge()
+ {
+ var source = @"
+using ForgeMap;
+
+public class Src { public string? Name { get; set; } }
+public class Dst { public string? Name { get; set; } }
+
+[ForgeMap]
+public partial class M
+{
+ [ForgeProperty(nameof(Src.Name), nameof(Dst.Name), Condition = nameof(IsNotNull))]
+ [ReverseForge]
+ public partial Dst Forge(Src source);
+ public partial Src ForgeReverse(Dst source);
+
+ private static bool IsNotNull(string? v) => v is not null;
+}";
+ var (diagnostics, trees) = RunGenerator(source);
+ Assert.Empty(diagnostics.Where(d => d.Severity == DiagnosticSeverity.Error));
+ var generated = string.Join("\n", trees.Select(t => t.GetText().ToString()));
+ Assert.Contains("if (IsNotNull(source.Name))", generated);
+ var reverseStart = generated.IndexOf("ForgeReverse");
+ Assert.True(reverseStart > 0, "Generated code should contain ForgeReverse body");
+ var reverseBody = generated.Substring(reverseStart);
+ Assert.DoesNotContain("IsNotNull(", reverseBody);
+ }
+}