From 6bd1fd30728c55c462893498f37a0fbcde919125 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 9 Nov 2026 12:28:22 -0500 Subject: [PATCH] test(scripts): per-row exclusion seam in CompileSurfaceParityTests; guard ScriptLogger/CompileLog (ScadaBridge#57) AccessorMirrorPairs rows now carry an ignoredRuntimeMethods list. An entry must exist on the runtime accessor and must be ABSENT from the mirror, so a stale or over-broad exclusion fails the theory. The (ScriptLogger, CompileLog) pair joins the guarded set with FlushDropped excluded (run-end flush called by ScriptRunLauncher, not script-reachable). Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV --- .../Scripts/CompileSurfaceParityTests.cs | 56 ++++++++++++++++--- 1 file changed, 49 insertions(+), 7 deletions(-) diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/CompileSurfaceParityTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/CompileSurfaceParityTests.cs index 45af5064..5b86748e 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/CompileSurfaceParityTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/CompileSurfaceParityTests.cs @@ -67,14 +67,44 @@ public class CompileSurfaceParityTests /// /// The runtime script accessor type whose public methods are enumerated. /// The compile-only mirror type that must cover those methods. + /// + /// Runtime method names that are public but NOT script-reachable and must therefore be absent + /// from the mirror (e.g. ScriptLogger.FlushDropped, called by the run launcher). Each + /// entry must exist on the runtime accessor and must NOT exist on the mirror, so a stale or + /// over-broad exclusion fails the theory instead of silently widening the hole (Gitea #57). + /// [Theory] [MemberData(nameof(AccessorMirrorPairs))] public void CompileAccessorMirror_Covers_RuntimeAccessor_MethodArities( - Type runtimeAccessor, Type compileMirror) + Type runtimeAccessor, Type compileMirror, string[] ignoredRuntimeMethods) { + var ignored = new HashSet(ignoredRuntimeMethods, StringComparer.Ordinal); var mirrorCounts = PublicInstanceMethodArityCounts(compileMirror); var runtimeCounts = PublicInstanceMethodArityCounts(runtimeAccessor); + var staleExclusions = ignored + .Where(name => !runtimeCounts.Keys.Any(k => k.Name == name)) + .OrderBy(name => name, StringComparer.Ordinal) + .ToList(); + Assert.True( + staleExclusions.Count == 0, + $"Exclusion(s) {string.Join(", ", staleExclusions)} name no public method on runtime accessor " + + $"'{runtimeAccessor.Name}' — remove the stale entry from AccessorMirrorPairs."); + + var exposedExclusions = ignored + .Where(name => mirrorCounts.Keys.Any(k => k.Name == name)) + .OrderBy(name => name, StringComparer.Ordinal) + .ToList(); + Assert.True( + exposedExclusions.Count == 0, + $"Excluded runtime method(s) {string.Join(", ", exposedExclusions)} are present on mirror " + + $"'{compileMirror.Name}'. An exclusion means the member is NOT script-reachable, so the " + + "mirror must not offer it — either drop it from the mirror or drop the exclusion."); + + runtimeCounts = runtimeCounts + .Where(kvp => !ignored.Contains(kvp.Key.Name)) + .ToDictionary(kvp => kvp.Key, kvp => kvp.Value); + var shortfalls = runtimeCounts .Where(kvp => mirrorCounts.GetValueOrDefault(kvp.Key) < kvp.Value) .OrderBy(kvp => kvp.Key.Name, StringComparer.Ordinal) @@ -99,27 +129,39 @@ public class CompileSurfaceParityTests /// Runtime script-accessor type ↔ compile-surface mirror pairs guarded by /// . /// - /// The runtime accessor and compile mirror type pairs, one per theory case. + /// + /// The runtime accessor, compile mirror and ignored-runtime-method triples, one per theory case. + /// public static IEnumerable AccessorMirrorPairs() => new[] { - new object[] { typeof(AttributeAccessor), typeof(ScriptCompileSurface.CompileAttributeAccessor) }, - new object[] { typeof(CompositionAccessor), typeof(ScriptCompileSurface.CompileCompositionAccessor) }, + new object[] { typeof(AttributeAccessor), typeof(ScriptCompileSurface.CompileAttributeAccessor), NoExclusions }, + new object[] { typeof(CompositionAccessor), typeof(ScriptCompileSurface.CompileCompositionAccessor), NoExclusions }, // ChildrenAccessor currently exposes only an indexer (no public instance // methods), so this row guards nothing today. It is kept so that if a // callable method is ever added to ChildrenAccessor, this Theory begins // enforcing a mirror for it automatically — rather than silently // re-opening the WaitAsync-class gap. - new object[] { typeof(ChildrenAccessor), typeof(ScriptCompileSurface.CompileChildrenAccessor) }, + new object[] { typeof(ChildrenAccessor), typeof(ScriptCompileSurface.CompileChildrenAccessor), NoExclusions }, // MES alarm-status API §5.2: the Alarms accessor joins the guarded set so a // future read method (e.g. an ActiveAsync convenience) cannot ship on the // runtime accessor while the design-time gate stays blind to it. - new object[] { typeof(AlarmsAccessor), typeof(ScriptCompileSurface.CompileAlarmsAccessor) }, + new object[] { typeof(AlarmsAccessor), typeof(ScriptCompileSurface.CompileAlarmsAccessor), NoExclusions }, // Script file access (design 2026-09-28): a new Files.* read method cannot ship on the // runtime accessor without its design-time mirror. - new object[] { typeof(FilesAccessor), typeof(ScriptCompileSurface.CompileFiles) }, + new object[] { typeof(FilesAccessor), typeof(ScriptCompileSurface.CompileFiles), NoExclusions }, + // Step logs (Gitea #57, register row 116): the Log global. FlushDropped() is the run-end + // closing-row flush called by ScriptRunLauncher, not by a script, so the mirror must omit it. + new object[] + { + typeof(ScriptLogger), typeof(ScriptCompileSurface.CompileLog), + new[] { nameof(ScriptLogger.FlushDropped) }, + }, }; + /// Exclusion list for a row whose mirror must cover every public runtime method. + private static readonly string[] NoExclusions = Array.Empty(); + /// /// Asserts that the public instance property + method member names of /// are a superset of those of