diff --git a/README.md b/README.md
index 404547e..6419718 100644
--- a/README.md
+++ b/README.md
@@ -38,8 +38,8 @@ Every host already has its own Roslyn constraints (Unity pins 4.8 because of `Sy
## C# in two lines
```xml
-
-
+
+
```
diff --git a/dotnet/CodeExec.Tests/RunnerTests.cs b/dotnet/CodeExec.Tests/RunnerTests.cs
index 3a92da0..79f2cb8 100644
--- a/dotnet/CodeExec.Tests/RunnerTests.cs
+++ b/dotnet/CodeExec.Tests/RunnerTests.cs
@@ -522,6 +522,20 @@ public static class Outer { public sealed class Inner { } }
[InlineData("Func twice = x => x * 2;\nnew[] { 1, 2 }.Select(twice).Sum()", "6", false)] // lambda caches only
[InlineData("var seen = new List();\nFunc twice = x => x * 2;\nseen.Add(twice(1));\nnew[] { 1, 2 }.Sum() + seen.Count", "4", false)]
[InlineData("const int K = 2;\nnew[] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 }.Length * K", "20", false)] // array data only
+ // readonly + immutable type: a lookup table, not state (spec §3.5)
+ [InlineData("static readonly string greeting = \"hi\";\ngreeting.Length", "2", false)]
+ [InlineData("public enum Color { Red, Green }\npublic static class D { public static readonly Color Default = Color.Green; public static readonly int? Max = 5; public static readonly TimeSpan T = TimeSpan.FromSeconds(1); }\n(int)D.Default + D.Max.Value + (int)D.T.TotalSeconds", "7", false)]
+ [InlineData("public static class G { public static int N { get; } = 3; }\nG.N", "3", false)]
+ [InlineData("public static class M { public static readonly IReadOnlyDictionary Map = new Dictionary { [\"a\"] = 1, [\"b\"] = 2 }; }\nM.Map[\"b\"]", "2", false)]
+ [InlineData("public static class I { public static readonly System.Collections.Immutable.ImmutableArray A = System.Collections.Immutable.ImmutableArray.Create(\"x\", \"y\"); }\nI.A.Length", "2", false)]
+ // readonly but mutable: arrays, mutable elements, or a mutable field next to an immutable one
+ [InlineData("public static class A { public static readonly int[] C = new int[1]; }\n++A.C[0]", "1", true)]
+ [InlineData("public static class R { public static readonly IReadOnlyList> L = new[] { new List() }; }\nR.L[0].Add(1);\nR.L[0].Count", "1", true)]
+ [InlineData("public static class X { public static readonly string S = \"s\"; public static int N; }\n++X.N", "1", true)]
+ [InlineData("public static class GL { public static List L { get; } = new List(); }\nGL.L.Add(1);\nGL.L.Count", "1", true)]
+ [InlineData("public static class P { public static readonly IntPtr Mem = System.Runtime.InteropServices.Marshal.AllocHGlobal(4); static P() { System.Runtime.InteropServices.Marshal.WriteInt32(Mem, 0); } }\nvar n = System.Runtime.InteropServices.Marshal.ReadInt32(P.Mem) + 1;\nSystem.Runtime.InteropServices.Marshal.WriteInt32(P.Mem, n);\nn", "1", true)]
+ // a view the snippet implements itself can hide state in its instance
+ [InlineData("public sealed class Ctr : IReadOnlyList { int n; public int this[int i] => ++n; public int Count => 1; public IEnumerator GetEnumerator() { yield return this[0]; } System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => GetEnumerator(); }\npublic static class S { public static readonly IReadOnlyList L = new Ctr(); }\nS.L[0]", "1", true)]
public void StatefulSnippetsNeverSeeAnotherRunsStatics(string code, string expected, bool stateful)
{
var runner = Fixtures.Runner(configure: o => o.Host = new DefaultAssemblyHost());
diff --git a/dotnet/CodeExec/Support.cs b/dotnet/CodeExec/Support.cs
index c971199..16dd3e3 100644
--- a/dotnet/CodeExec/Support.cs
+++ b/dotnet/CodeExec/Support.cs
@@ -119,8 +119,9 @@ static string WithSeparator(string path) =>
///
/// Whether a snippet assembly keeps state between runs if it is reused: a static field (an auto-property's or an
- /// event's backing field included) declared by one of the agent's own types. The compiler's own types (lambda caches,
- /// array-initializer data) are stateless for this purpose.
+ /// event's backing field included) declared by one of the agent's own types, unless it is readonly and of an
+ /// immutable type (spec §3.5): a lookup table or a value computed once is not state. The compiler's own types (lambda
+ /// caches, array-initializer data) are stateless for this purpose.
///
static class StaticState
{
@@ -129,11 +130,14 @@ public static bool Has(Assembly assembly)
const BindingFlags flags = BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.DeclaredOnly;
try
{
- foreach (var type in assembly.GetTypes())
+ // The compiler's own types (lambda caches, array-initializer data, collection-expression wrappers) are skipped.
+ var types = assembly.GetTypes().Where(t => !t.IsDefined(typeof(CompilerGeneratedAttribute), false)).ToList();
+ // A collection view the snippet implements (or derives) itself may keep state in its instance: then no view
+ // type is trusted to be immutable.
+ var trustViews = !types.Any(ImplementsView);
+ foreach (var type in types)
{
- if (type.IsDefined(typeof(CompilerGeneratedAttribute), false))
- continue;
- if (type.GetFields(flags).Any(f => !f.IsLiteral))
+ if (type.GetFields(flags).Any(f => !f.IsLiteral && !(f.IsInitOnly && IsImmutable(f.FieldType, trustViews))))
return true;
}
return false;
@@ -143,6 +147,49 @@ public static bool Has(Assembly assembly)
return true;
}
}
+
+ static readonly HashSet ImmutableValues = new HashSet
+ {
+ typeof(string), typeof(decimal), typeof(DateTime), typeof(DateTimeOffset), typeof(TimeSpan), typeof(Guid),
+ };
+
+ // Read-only views: their contents cannot change through the declared type (a cast back to a mutable one can).
+ static readonly HashSet ReadOnlyViews = new HashSet
+ {
+ "System.Collections.Generic.IReadOnlyCollection`1", "System.Collections.Generic.IReadOnlyList`1",
+ "System.Collections.Generic.IReadOnlyDictionary`2", "System.Collections.Generic.IReadOnlySet`1",
+ "System.Collections.ObjectModel.ReadOnlyCollection`1", "System.Collections.ObjectModel.ReadOnlyDictionary`2",
+ };
+
+ /// Immutable by declared type (spec §3.5). Arrays and pointer-sized integers (native memory) are mutable.
+ static bool IsImmutable(Type type, bool trustViews)
+ {
+ if ((type.IsPrimitive && type != typeof(IntPtr) && type != typeof(UIntPtr)) || type.IsEnum || ImmutableValues.Contains(type))
+ return true;
+ var underlying = Nullable.GetUnderlyingType(type);
+ if (underlying != null)
+ return IsImmutable(underlying, trustViews);
+ return trustViews && IsView(type) && type.GetGenericArguments().All(t => IsImmutable(t, trustViews));
+ }
+
+ static bool IsView(Type type)
+ {
+ if (!type.IsGenericType)
+ return false;
+ var definition = type.GetGenericTypeDefinition();
+ // Nested types there are the mutable builders and enumerators.
+ return (definition.Namespace == "System.Collections.Immutable" && !definition.IsNested) || ReadOnlyViews.Contains(definition.FullName ?? "");
+ }
+
+ static bool ImplementsView(Type type)
+ {
+ for (var t = (Type?)type; t != null; t = t.BaseType)
+ {
+ if (IsView(t))
+ return true;
+ }
+ return type.GetInterfaces().Any(IsView);
+ }
}
/// Formats a runtime as C# source: global::System.Collections.Generic.List<global::System.Int32>.
diff --git a/dotnet/Directory.Build.props b/dotnet/Directory.Build.props
index 5833b19..373ca97 100644
--- a/dotnet/Directory.Build.props
+++ b/dotnet/Directory.Build.props
@@ -6,7 +6,7 @@
true
$(NoWarn);CS1591
true
- 0.3.0
+ 0.3.1
Ivan Murzak
Copyright (c) 2026 Ivan Murzak
https://github.com/IvanMurzak/Code-Exec
diff --git a/spec/contract.md b/spec/contract.md
index 683ba86..34b8ed7 100644
--- a/spec/contract.md
+++ b/spec/contract.md
@@ -104,6 +104,17 @@ asked, so the snippet sees the host's live statics, never a second copy. On Mono
identical requests reuse the already-loaded assembly (cache) — unless the snippet (or an include) declares static state
of its own (a static field, auto-property or field-like event in one of its types): then every run loads a fresh copy,
so no run ever sees another run's statics. Such a snippet costs one assembly per run until the host's domain reloads.
+A `static readonly` field (or a getter-only static auto-property) is **not** state when its declared type is immutable:
+`string`, a primitive other than `IntPtr` / `UIntPtr`, an enum, `decimal`, `DateTime`, `DateTimeOffset`, `TimeSpan`,
+`Guid`, a nullable of one of these, or a read-only / immutable collection of immutable elements
+(`IReadOnlyCollection`, `IReadOnlyList`, `IReadOnlyDictionary`, `IReadOnlySet`, `ReadOnlyCollection`,
+`ReadOnlyDictionary`, `System.Collections.Immutable.*`). So a lookup table like
+`static readonly IReadOnlyDictionary Map` keeps the snippet cached. Arrays are mutable
+(`static readonly int[]` is state), and no collection type counts as immutable when the snippet itself implements or
+derives from one of them. Consequences of reuse on Mono, by design: such a field's initializer runs once per loaded copy,
+so a value read from live host state keeps its first value (`static readonly int Cameras = FindObjectsOfType().Length`,
+`static readonly DateTime Started = DateTime.Now`: read live values in a method or property instead); and a host object
+behind a read-only declared type, or a cast back to a mutable type, is not detected.
## 4. Python snippet semantics
@@ -150,6 +161,7 @@ Lengths are in characters: UTF-16 code units in C#, code points in Python. A lim
## Changelog
+- **v0.3.1** — C#: `static readonly` fields of immutable types are not static state (§3.5). Python is unchanged.
- **v0.3** — output limits (§6): request fields `maxValueChars`, `maxLogChars`, `maxLogLineChars`, `maxErrorChars`
(defaults 20000 / 20000 / 2000 / 20000, the caps Unreal-MCP and Godot-MCP applied on their side) and response field
`truncated`; long tracebacks keep their first and last 10 frames; at most 20 diagnostics. Includes stay inside the