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
This commit is contained in:
Joseph Doherty
2026-11-09 12:28:22 -05:00
parent 79957c4b96
commit 6bd1fd3072
@@ -67,14 +67,44 @@ public class CompileSurfaceParityTests
/// </summary>
/// <param name="runtimeAccessor">The runtime script accessor type whose public methods are enumerated.</param>
/// <param name="compileMirror">The compile-only mirror type that must cover those methods.</param>
/// <param name="ignoredRuntimeMethods">
/// Runtime method names that are public but NOT script-reachable and must therefore be absent
/// from the mirror (e.g. <c>ScriptLogger.FlushDropped</c>, 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).
/// </param>
[Theory]
[MemberData(nameof(AccessorMirrorPairs))]
public void CompileAccessorMirror_Covers_RuntimeAccessor_MethodArities(
Type runtimeAccessor, Type compileMirror)
Type runtimeAccessor, Type compileMirror, string[] ignoredRuntimeMethods)
{
var ignored = new HashSet<string>(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
/// <see cref="CompileAccessorMirror_Covers_RuntimeAccessor_MethodArities"/>.
/// </summary>
/// <returns>The runtime accessor and compile mirror type pairs, one per theory case.</returns>
/// <returns>
/// The runtime accessor, compile mirror and ignored-runtime-method triples, one per theory case.
/// </returns>
public static IEnumerable<object[]> 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) },
},
};
/// <summary>Exclusion list for a row whose mirror must cover every public runtime method.</summary>
private static readonly string[] NoExclusions = Array.Empty<string>();
/// <summary>
/// Asserts that the public instance property + method member names of
/// <paramref name="surface"/> are a superset of those of