Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions docs/docs/configuration/analyzer-diagnostics/RMG096.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
---
sidebar_label: RMG096
description: 'Mapperly analyzer diagnostic RMG096 — Ignored mapping is missing Justification'
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nitpick: lowercase justification

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Same at several locations in this document.

---

# RMG096 — Ignored mapping is missing Justification

If Mapperly detects a mapping ignore without a Justification, `RMG096` is emitted.

The following attributes may raise this diagnostic:

- `MapperIgnoreSource`
- `MapperIgnoreTarget`
- `MapperIgnoreSourceValue`
- `MapperIgnoreTargetValue`
Comment on lines +8 to +15
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

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

The documentation claims RMG096 may be raised by MapperIgnoreTarget / MapperIgnoreSourceValue / MapperIgnoreTargetValue, but the current implementation only reports it for MapperIgnoreSource (and only that attribute has Justification in this PR). Either narrow the doc to the currently supported attribute(s) or implement the diagnostic for the listed attributes before publishing this page.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

We should add MapperIgnoreAttribute.


To fix this, add a Justification to the attribute, for example:

```csharp
public partial class Mapper {
[MapperIgnoreSource(nameof(SourceClass.IgnoredProperty), Justification = "This source member is not relevant for the target mapping")]
public partial TargetClass Map(SourceClass source);
}

class SourceClass {
string IgnoredProperty { get; set; }
}
Comment on lines +24 to +27
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

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

In the example, IgnoredProperty has no access modifier, so it's private by default and wouldn't be a mappable member in most scenarios. Marking it public (or using a public property in the sample) would make the example align with the intended use of ignore attributes.

Copilot uses AI. Check for mistakes.

class TargetClass { }
```
10 changes: 10 additions & 0 deletions src/Riok.Mapperly.Abstractions/MapperIgnoreSourceAttribute.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,14 @@ public MapperIgnoreSourceAttribute(string source)
/// Gets the source property name which should be ignored from the mapping.
/// </summary>
public string Source { get; }

/// <summary>
/// Gets or sets the justification for ignoring the source property.
/// This is only used for documentation purposes and does not have any effect on the mapping.
/// </summary>
/// <remarks>
/// You can enforce the presence of Justifications by setting the diagnostic severity of <c>RMG096</c> in your
/// <c>.editorconfig</c> to any value other than <c>hidden</c>.
/// </remarks>
public string? Justification { get; set; }
}
1 change: 1 addition & 0 deletions src/Riok.Mapperly/AnalyzerReleases.Shipped.md
Original file line number Diff line number Diff line change
Expand Up @@ -214,3 +214,4 @@ RMG092 | Mapper | Error | Source type is not assignable to the included source t
RMG093 | Mapper | Error | Target type is not assignable to the included target type
RMG094 | Mapper | Error | Circular existing target mapping without setter detected
RMG095 | Mapper | Warning | Invalid MSBuild configuration option
RMG096 | Mapper | Hidden | A MapperIgnore* attribute does not specify a Justification
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

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

This adds a brand-new analyzer rule (RMG096) to AnalyzerReleases.Shipped.md. New rules are typically tracked in AnalyzerReleases.Unshipped.md until they are actually released; otherwise release tracking will claim the rule was already shipped.

Copilot uses AI. Check for mistakes.
21 changes: 15 additions & 6 deletions src/Riok.Mapperly/Configuration/MapperConfigurationReader.cs
Original file line number Diff line number Diff line change
Expand Up @@ -217,16 +217,16 @@ private MembersMappingConfiguration BuildMembersConfig(MappingConfigurationRefer
if (configRef.Method == null)
return MapperConfiguration.Members;

var ignoredSourceMembers = _dataAccessor
var ignoreSourceMemberAttributes = _dataAccessor
.Access<MapperIgnoreSourceAttribute>(configRef.Method)
.Select(x => x.Source)
.WhereNotNull()
.WhereNotNullBy(static attr => attr.Source)
.ToList();
var ignoredTargetMembers = _dataAccessor
var ignoredSourceMembers = ignoreSourceMemberAttributes.Select(x => x.Source).ToList();
var ignoreTargetMemberAttributes = _dataAccessor
.Access<MapperIgnoreTargetAttribute>(configRef.Method)
.Select(x => x.Target)
.WhereNotNull()
.WhereNotNullBy(static attr => attr.Target)
.ToList();
var ignoredTargetMembers = ignoreTargetMemberAttributes.Select(x => x.Target).ToList();
var memberValueConfigurations = _dataAccessor.Access<MapValueAttribute, MemberValueMappingConfiguration>(configRef.Method).ToList();
var memberConfigurations = _dataAccessor
.Access<MapPropertyAttribute, MemberMappingConfiguration>(configRef.Method)
Expand Down Expand Up @@ -259,6 +259,15 @@ private MembersMappingConfiguration BuildMembersConfig(MappingConfigurationRefer
return MapperConfiguration.Members;
}

foreach (var missingIgnoreSourceJustificationAttribute in ignoreSourceMemberAttributes.Where(attr => attr.Justification is null))
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

When adding support for the other attributes I'd move this into its own method and call it just next to resolving the attributes.

{
_diagnostics.ReportDiagnostic(
DiagnosticDescriptors.MapperIgnoreAttributeMissingJustification,
configRef.Method,
missingIgnoreSourceJustificationAttribute.Source
);
Comment on lines +262 to +268
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

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

The new RMG096 diagnostic only checks attr.Justification is null. This allows Justification = "" or whitespace to pass even though it provides no documentation. Consider treating null/empty/whitespace as missing (eg string.IsNullOrWhiteSpace).

Copilot uses AI. Check for mistakes.
Comment on lines +264 to +268
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

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

RMG096 is reported on configRef.Method, so the diagnostic will point at the method declaration rather than the specific [MapperIgnoreSource(...)] attribute that is missing a justification. For better UX, consider carrying the attribute syntax location through configuration reading (eg via a small config type inheriting HasSyntaxReference) and reporting the diagnostic at that attribute location.

Copilot uses AI. Check for mistakes.
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Add a help link for the added docs, see other diagnostics on how to do it.


foreach (var invalidMemberConfig in memberValueConfigurations.Where(x => !x.IsValid))
{
_diagnostics.ReportDiagnostic(DiagnosticDescriptors.InvalidMapValueAttributeUsage, invalidMemberConfig.Location);
Expand Down
9 changes: 9 additions & 0 deletions src/Riok.Mapperly/Diagnostics/DiagnosticDescriptors.cs
Original file line number Diff line number Diff line change
Expand Up @@ -808,6 +808,15 @@ public static class DiagnosticDescriptors
true
);

public static readonly DiagnosticDescriptor MapperIgnoreAttributeMissingJustification = new(
"RMG096",
"Ignored mapping is missing Justification",
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

nitpick: I'd lowercase the justification.

"The ignored mapping of {0} does not specify a Justification, consider adding one for documentation purposes",
DiagnosticCategories.Mapper,
DiagnosticSeverity.Hidden,
true
);

private static string BuildHelpUri(string id)
{
#if ENV_NEXT
Expand Down
8 changes: 8 additions & 0 deletions src/Riok.Mapperly/Helpers/EnumerableExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,14 @@ public static IEnumerable<T> WhereNotNull<T>(this IEnumerable<T?> enumerable)
#nullable restore
}

public static IEnumerable<TItem> WhereNotNullBy<TItem, TSelector>(this IEnumerable<TItem> enumerable, Func<TItem, TSelector?> selector)
where TSelector : notnull
{
#nullable disable
return enumerable.Where(x => selector(x) != null);
#nullable restore
}

Comment on lines +21 to +28
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

What is the benefit of this new extension method? The WhereNotNull doesn't accept a parameter and ensures the source enumerable is notnull-annotated after calling the method. This does not work the same with a selector 🤔

Copy link
Copy Markdown
Contributor Author

@OlliMartin OlliMartin Mar 14, 2026

Choose a reason for hiding this comment

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

Thanks for your review @latonz!
Planning to continue on this tomorrow, but wanted to answer to this thread already.

Got to be honest, I don't know why the old one (WhereNotNull) is there, since the Target Property is not nullable when I inspect it, so my thought was that it was added because the contract might be violated during runtime (i.e. property is defined as string Target { get; } but the target may be null at runtime.

Now I'm guessing it has to do with different Roslyn versions or the like, in which case I don't know a good approach other than iterating the list twice..

WhereNotNullBy (after removing the unnecessary notnull) to collect the attributes and then calling WhereNotNull on the target so that Target is notnull.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

It may just be a leftover of a refactoring…

public static IEnumerable<T> SkipLast<T>(this IEnumerable<T> enumerable)
{
using var enumerator = enumerable.GetEnumerator();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,7 @@ public MapperIgnoreObsoleteMembersAttribute(Riok.Mapperly.Abstractions.IgnoreObs
public sealed class MapperIgnoreSourceAttribute : System.Attribute
{
public MapperIgnoreSourceAttribute(string source) { }
public string? Justification { get; set; }
public string Source { get; }
}
[System.AttributeUsage(System.AttributeTargets.Method, AllowMultiple=true)]
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
using System.Collections.Immutable;
using Microsoft.CodeAnalysis;
using Riok.Mapperly.Diagnostics;

namespace Riok.Mapperly.Tests.DocumentationDiagnostics;

public class MapperIgnoreJustificationTests
{
private TestHelperOptions TestHelperOptions =>
TestHelperOptions.Default with
{
IgnoredDiagnostics = TestHelperOptions
.DefaultIgnoredDiagnostics.Except([DiagnosticDescriptors.MapperIgnoreAttributeMissingJustification])
.ToImmutableHashSet(),
AllowedDiagnosticSeverities = new HashSet<DiagnosticSeverity>() { DiagnosticSeverity.Hidden },
};
Comment on lines +9 to +16
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

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

The property name TestHelperOptions shadows the TestHelperOptions type name, which makes the initializer hard to read and easy to misinterpret. Renaming this to something like Options/HelperOptions would improve clarity and avoid confusion in future edits.

Copilot uses AI. Check for mistakes.

[Fact]
public void MapperIgnoreSourceShouldNotReportAttributeWithJustification()
{
var source = TestSourceBuilder.MapperWithBodyAndTypes(
"[MapperIgnoreSource(nameof(A.Value), Justification = \"Justification\")] partial B Map(A source);",
"""
class A
{
public int Value { get; set; }
}
""",
"""
class B { }
"""
);

TestHelper.GenerateMapper(source, TestHelperOptions).Should().HaveAssertedAllDiagnostics();
}

[Fact]
public void MapperIgnoreSourceShouldReportMissingJustification()
{
var source = TestSourceBuilder.MapperWithBodyAndTypes(
"[MapperIgnoreSource(nameof(A.Value))] partial B Map(A source);",
"""
class A
{
public int Value { get; set; }
}
""",
"""
class B { }
"""
);

TestHelper
.GenerateMapper(source, TestHelperOptions)
.Should()
.HaveDiagnostic(
DiagnosticDescriptors.MapperIgnoreAttributeMissingJustification,
"The ignored mapping of Value does not specify a Justification, consider adding one for documentation purposes"
)
.HaveAssertedAllDiagnostics();
}
}
2 changes: 1 addition & 1 deletion test/Riok.Mapperly.Tests/Mapping/DerivedTypeTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -228,7 +228,7 @@ public Task WithBaseTypeMapPropertyAndSeparateMethodShouldWork()
[MapProperty(nameof(A.BaseValueA), nameof(B.BaseValueB)]
public partial B Map(A src);

[MapperIgnoreSource(nameof(A.BaseValueA)]
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I stumbled across this while going through the failing snapshot tests:
How/why does this work in the first place, I can't wrap my head around it - it is not valid C# syntax, right?
There is a missing ) here, it should be [MapperIgnoreSource(nameof(A.BaseValueA))]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I think the way to go would be disabling the new diagnostic for all tests except where we explicitly want to test it. I think you should be able to control editorsettings configs in the tests using TestHelperOptions.AnalyzerConfigOptions

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Oops, my comment does not really answer your question but a question you raised in the PR description.

It sometimes still works, even if the syntax tree is not parsed 100% correct as the parsed parts may still be enough fot mapperly to work🤷

[MapperIgnoreSource(nameof(A.BaseValueA), Justification = "BaseValueA is ignored for testing purposes")]
[MapperIgnoreTarget(nameof(B.BaseValueB)]
Comment on lines 228 to 232
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

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

The C# code embedded in the raw string appears to have malformed attribute syntax (missing a closing ) before ]) for [MapProperty(...] and [MapperIgnoreTarget(...]. Even if the generator can run with parse errors, this makes the test source harder to reason about and can hide real failures; please fix the attribute syntax in the test snippet.

Copilot uses AI. Check for mistakes.
public partial BSubType1 Map(ASubType1 src);
""",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ public Task UsesMapperIgnoreSourceFromTargetMapper()
{
var source = TestSourceBuilder.MapperWithBodyAndTypes(
"""
[MapperIgnoreSource(nameof(A.Ignored))]
[MapperIgnoreSource(nameof(A.Ignored), Justification = "Property is ignored for testing purposes")]
partial B MapOther(A a);

[IncludeMappingConfiguration(nameof(MapOther))]
Expand Down
2 changes: 1 addition & 1 deletion test/Riok.Mapperly.Tests/Mapping/ReferenceHandlingTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -436,7 +436,7 @@ public Task MultipleUserDefinedWithSpecifiedDefault()
"""
public partial B Map(A a);
private partial D MapD(C source);
[MapperIgnoreSource("IntValue")]
[MapperIgnoreSource("IntValue", Justification = "IntValue is ignored for testing purposes")]
[MapperIgnoreTarget("IntValue")]
[UserMapping(Default = true)]
private partial D MapDIgnore(C source);
Expand Down
15 changes: 10 additions & 5 deletions test/Riok.Mapperly.Tests/TestHelperOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,18 @@ public record TestHelperOptions(
IReadOnlyDictionary<string, string>? AnalyzerConfigOptions = null
)
{
public static readonly IReadOnlySet<DiagnosticDescriptor> DefaultIgnoredDiagnostics = new HashSet<DiagnosticDescriptor>
{
// ignore NoMemberMappings as a lot of tests use this for simplicity
DiagnosticDescriptors.NoMemberMappings,
// ignore missing Justification properties unless specifically tested for,
// in which case the diagnostic is removed before using the options.
DiagnosticDescriptors.MapperIgnoreAttributeMissingJustification,
};
Comment on lines +22 to +29
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

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

DefaultIgnoredDiagnostics is backed by a mutable HashSet but exposed as a static shared IReadOnlySet. Because the underlying set can still be mutated (via casts or by keeping a reference), this can create test cross-talk and order-dependent failures. Prefer an immutable collection (eg ImmutableHashSet) or create a new set per TestHelperOptions instance.

Copilot uses AI. Check for mistakes.

public static readonly TestHelperOptions Default = new(
AllowedDiagnosticSeverities: new HashSet<DiagnosticSeverity>(),
IgnoredDiagnostics: new HashSet<DiagnosticDescriptor>
{
// ignore NoMemberMappings as a lot of tests use this for simplicity
DiagnosticDescriptors.NoMemberMappings,
}
IgnoredDiagnostics: DefaultIgnoredDiagnostics
);

public static readonly TestHelperOptions DisabledNullable = Default with { NullableOption = NullableContextOptions.Disable };
Expand Down