Skip to content

Commit d187cf6

Browse files
Fix #264: enforce init-only contract on immutable records (#274)
Add reflection-based regression test verifying TrayIconSpec, WindowSpec, Command, and Command<T> properties use init (not set). Update doc comments to say 'init-only' explicitly so tools that render init as set won't mislead reviewers. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 2d9b743 commit d187cf6

4 files changed

Lines changed: 66 additions & 4 deletions

File tree

src/Reactor/Core/Command.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
namespace Microsoft.UI.Reactor.Core;
22

33
/// <summary>
4-
/// Immutable command descriptor that bundles an action with its metadata (label, icon,
4+
/// Immutable (init-only) command descriptor that bundles an action with its metadata (label, icon,
55
/// keyboard accelerator, enabled state). Define once, use in any surface:
66
/// var save = new Command { Label = "Save", Execute = () => Save(), Icon = SymbolIcon("Save") };
77
/// AppBarButton(save) // toolbar
@@ -44,7 +44,7 @@ public sealed record Command
4444
}
4545

4646
/// <summary>
47-
/// Parameterized command descriptor. The action receives an argument of type <typeparamref name="T"/>,
47+
/// Immutable (init-only) parameterized command descriptor. The action receives an argument of type <typeparamref name="T"/>,
4848
/// enabling a single command definition to operate on different targets:
4949
/// var delete = new Command&lt;Item&gt; { Label = "Delete", Execute = item => Remove(item) };
5050
/// MenuItem(delete, selectedItem)

src/Reactor/Hosting/Shell/TrayIconSpec.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
namespace Microsoft.UI.Reactor;
22

33
/// <summary>
4-
/// Immutable, declarative description of a system-tray icon. Hand to
4+
/// Immutable (init-only) declarative description of a system-tray icon. Hand to
55
/// <see cref="ReactorApp.OpenTrayIcon"/> to register; hand to
66
/// <see cref="ReactorTrayIcon.Update"/> to diff and re-apply only changed
77
/// fields. (spec 036 §11.4)

src/Reactor/Hosting/WindowSpec.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
namespace Microsoft.UI.Reactor;
44

55
/// <summary>
6-
/// Immutable, declarative description of a top-level Reactor window. Hand to
6+
/// Immutable (init-only) declarative description of a top-level Reactor window. Hand to
77
/// <see cref="ReactorApp.OpenWindow(WindowSpec, Func{Component}, Action{Microsoft.UI.Reactor.Hosting.ReactorHost})"/>
88
/// to open a window; hand to <see cref="ReactorWindow.Update"/> to diff
99
/// against the current spec and apply only changed fields.
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
using System.Reflection;
2+
using System.Runtime.CompilerServices;
3+
using Microsoft.UI.Reactor;
4+
using Microsoft.UI.Reactor.Core;
5+
using Xunit;
6+
7+
namespace Reactor.Tests;
8+
9+
/// <summary>
10+
/// Regression test for issue #264: ensures public record types documented as
11+
/// "immutable" truly have init-only setters (not plain set).
12+
/// </summary>
13+
public class ImmutableRecordContractTests
14+
{
15+
/// <summary>
16+
/// At the IL level, an <c>init</c> setter is a <c>set</c> method whose
17+
/// return type carries <c>modreq(IsExternalInit)</c>. This helper detects that.
18+
/// </summary>
19+
private static bool IsInitOnly(PropertyInfo property)
20+
{
21+
var setter = property.GetSetMethod(nonPublic: true);
22+
if (setter is null)
23+
return false; // read-only (no setter at all) — still immutable
24+
25+
var returnParam = setter.ReturnParameter;
26+
return returnParam.GetRequiredCustomModifiers()
27+
.Any(t => t.FullName == "System.Runtime.CompilerServices.IsExternalInit");
28+
}
29+
30+
public static TheoryData<Type> ImmutableRecordTypes => new()
31+
{
32+
{ typeof(TrayIconSpec) },
33+
{ typeof(WindowSpec) },
34+
{ typeof(Command) },
35+
{ typeof(Command<>) },
36+
};
37+
38+
[Theory]
39+
[MemberData(nameof(ImmutableRecordTypes))]
40+
public void All_Public_Properties_Are_InitOnly(Type type)
41+
{
42+
var properties = type.GetProperties(BindingFlags.Public | BindingFlags.Instance)
43+
.Where(p => p.GetSetMethod(nonPublic: true) is not null);
44+
45+
foreach (var prop in properties)
46+
{
47+
Assert.True(
48+
IsInitOnly(prop),
49+
$"{type.Name}.{prop.Name} has a plain 'set' accessor — " +
50+
$"expected 'init' to preserve immutability contract.");
51+
}
52+
}
53+
54+
[Theory]
55+
[MemberData(nameof(ImmutableRecordTypes))]
56+
public void Has_At_Least_One_Public_Property(Type type)
57+
{
58+
// Sanity check: ensure the test is actually verifying something.
59+
var props = type.GetProperties(BindingFlags.Public | BindingFlags.Instance);
60+
Assert.NotEmpty(props);
61+
}
62+
}

0 commit comments

Comments
 (0)