diff --git a/.github/actions/ci-versioning/action.yml b/.github/actions/ci-versioning/action.yml index 15b8880..3259ed8 100644 --- a/.github/actions/ci-versioning/action.yml +++ b/.github/actions/ci-versioning/action.yml @@ -806,6 +806,13 @@ runs: $rows = @() $unverified = 0 + # Unverified splits into two different problems and the summary has to say which. + # "Could not resolve it" is a closure gap: a type the record needs is absent and so + # is its namespace, so nothing here could have supplied it. "Could not attribute it" + # is an ownership problem: the record resolved, but ownership was inferred from a + # namespace prefix. Different fixes, tracked separately (internal-tickets #32, #31). + $unresolved = 0 + $unattributed = 0 $coverage = $null $configuration = $null $status = $null @@ -829,7 +836,14 @@ runs: Cause = $d.Cause Context = if ($context.Count -gt 0) { $context -join '; ' } else { '' } } - } else { $unverified++ } + } else { + $unverified++ + switch ($d.Path) { + 'UnresolvableTypeAbsent' { $unresolved++ } + 'UnresolvableFromEvents' { $unresolved++ } + default { $unattributed++ } + } + } } } @@ -853,6 +867,10 @@ runs: $md += "| Classification | ``$classification`` |" $md += "| Attribution basis | ``$attributionBasis`` |" $md += "| Reported unverified | $unverified |" + if ($unverified -gt 0) { + $md += "| — could not be resolved (closure gap) | $unresolved |" + $md += "| — could not be attributed (inferred ownership) | $unattributed |" + } if ($coverage) { $md += "| Surface examined | $($coverage.SubjectTypes) subject types across $($coverage.SubjectAssemblies) subject assemblies |" $md += "| Dataset versions | $(if ($coverage.DatasetVersions -eq 0) { 'all staged' } else { 'previous only' }) |" diff --git a/tools/VersioningRunner/src/VersioningRunner.Tests/RunCommandTests.cs b/tools/VersioningRunner/src/VersioningRunner.Tests/RunCommandTests.cs index 4621412..2f88d9b 100644 --- a/tools/VersioningRunner/src/VersioningRunner.Tests/RunCommandTests.cs +++ b/tools/VersioningRunner/src/VersioningRunner.Tests/RunCommandTests.cs @@ -521,36 +521,113 @@ public void SubjectListNamingNothingLoaded_YieldsNoNamespacesRatherThanTheClosur private const string MethodCause = "Method ApplyDuctInsulation from { \"_t\" : \"System.Type\", \"Name\" : \"BH.Revit.Engine.MechanicalPlumbing.Compute, Revit_MechanicalPlumbing_Engine_2022, Version=9.0.0.0, Culture=neutral, PublicKeyToken=null\", \"_bhomVersion\" : \"9.2\" } failed to deserialise."; + // The object-record shapes, also verbatim from a real run (BHoM/BHoM 33849699768). + // A nested fragment from a downstream toolkit names itself here. + private const string ConvertCause = + "Failed to convert the string into a type: BH.oM.Adapters.GSA.MaterialFragments.Fabric"; + private const string UnknownCause = + "The type BH.oM.Adapters.GSA.MaterialFragments.Fabric from version 9.2 is unknown -> data returned as custom objects."; + + // A closure holding Structure_oM: the namespace is present, so a type missing from + // it is a removal rather than a gap. GSA is absent entirely. + private static LoadedTypeIndex StructureOnly() => new( + new HashSet(["BH.oM.Structure.Elements.Panel", "BH.oM.Structure.Elements.Edge"], StringComparer.Ordinal), + new HashSet(["BH.oM.Structure.Elements"], StringComparer.Ordinal)); + [Fact] public void OnlyNonBHoMTypesFailed_IsClassifiedUnresolvable() { Assert.Equal("Autodesk.Revit.DB.Document", - RunCommand.ClassifyUnresolvableCause([RevitCause, MethodCause])); + RunCommand.ClassifyUnresolvableCause([RevitCause, MethodCause], StructureOnly()).Cause); } [Fact] public void MethodEventEmbeddingABHoMTypeInJson_DoesNotCountAsATypeFailure() { - // The Method event embeds a BH. type in its JSON payload. Only "Type ..." - // events name what failed, or every Revit failure would read as real. - Assert.NotNull(RunCommand.ClassifyUnresolvableCause([RevitCause, MethodCause])); + // The Method event embeds a BH. type in its JSON payload. Only the type-naming + // shapes say what failed, or every Revit failure would read as real. + Assert.NotNull(RunCommand.ClassifyUnresolvableCause([RevitCause, MethodCause], StructureOnly()).Cause); } + // The removal fail-safe, and the reason this is keyed on the loaded set rather than + // on absence alone. Bar is gone from an assembly that IS loaded and whose namespace + // survives, so it is a removal and must stay real. Classifying it away would make the + // check blind to exactly what it exists to catch. [Fact] - public void ABHoMTypeAmongTheFailures_IsReal() + public void TypeMissingFromALoadedNamespace_IsReal() { Assert.Null(RunCommand.ClassifyUnresolvableCause([ RevitCause, "Type BH.oM.Structure.Elements.Bar, Structure_oM, Version=9.0.0.0 failed to deserialise.", - MethodCause])); + MethodCause], StructureOnly()).Cause); + } + + // The case the old `StartsWith("BH.")` test got wrong, and the whole point of the + // change. This type is another repository's, its namespace is nowhere in the closure, + // and it starts with BH. — so the old test called it real and failed the build on it. + [Theory] + [InlineData(ConvertCause)] + [InlineData(UnknownCause)] + public void BHoMTypeWhoseNamespaceIsNotLoadedAtAll_IsUnresolvable(string message) + { + var (cause, fromPayload) = RunCommand.ClassifyUnresolvableCause([message], StructureOnly()); + Assert.Equal("BH.oM.Adapters.GSA.MaterialFragments.Fabric", cause); + Assert.True(fromPayload, "a payload-shaped message must record UnresolvableTypeAbsent, not the signature path"); + } + + // Mixed: one removal, one closure gap. The removal outranks, because losing a real + // regression is the worse error and the removed type has its own record besides. + [Fact] + public void ARemovalAlongsideAClosureGap_StaysReal() + { + Assert.Null(RunCommand.ClassifyUnresolvableCause([ + ConvertCause, + "Failed to convert the string into a type: BH.oM.Structure.Elements.Bar"], StructureOnly()).Cause); + } + + [Fact] + public void TypePresentInTheLoadedSet_IsNotABlocker() + { + Assert.Null(RunCommand.ClassifyUnresolvableCause([ + "Failed to convert the string into a type: BH.oM.Structure.Elements.Panel"], StructureOnly()).Cause); } [Fact] public void NoTypeEventsAtAll_IsReal() { - Assert.Null(RunCommand.ClassifyUnresolvableCause([MethodCause])); - Assert.Null(RunCommand.ClassifyUnresolvableCause([])); - Assert.Null(RunCommand.ClassifyUnresolvableCause(["", " "])); + Assert.Null(RunCommand.ClassifyUnresolvableCause([MethodCause], StructureOnly()).Cause); + Assert.Null(RunCommand.ClassifyUnresolvableCause([], StructureOnly()).Cause); + Assert.Null(RunCommand.ClassifyUnresolvableCause(["", " "], StructureOnly()).Cause); + } + + // An absent index means the caller is not exercising resolution. Decline rather than + // guess: with nothing known to be loaded, every named type looks absent, and + // reclassifying on that would silently disarm the check. + [Fact] + public void EmptyIndex_DeclinesToClassify() + { + Assert.Null(RunCommand.ClassifyUnresolvableCause([RevitCause], LoadedTypeIndex.Empty).Cause); + Assert.Null(RunCommand.ClassifyUnresolvableCause([ConvertCause], LoadedTypeIndex.Empty).Cause); + } + + [Fact] + public void NamedFailingType_ParsesAllThreeEventShapes() + { + Assert.Equal("Autodesk.Revit.DB.Document", RunCommand.NamedFailingType(RevitCause)); + Assert.Equal("BH.oM.Adapters.GSA.MaterialFragments.Fabric", RunCommand.NamedFailingType(ConvertCause)); + Assert.Equal("BH.oM.Adapters.GSA.MaterialFragments.Fabric", RunCommand.NamedFailingType(UnknownCause)); + Assert.Null(RunCommand.NamedFailingType(MethodCause)); + Assert.Null(RunCommand.NamedFailingType("")); + } + + // "from version unknown" occurs on nested records; the version clause must still be + // cut or the type name carries it and never matches the loaded set. + [Fact] + public void UnknownShapeWithUnknownVersion_StillYieldsTheBareType() + { + Assert.Equal("BH.oM.Adapters.Sharepoint.Requests.OrCondition", + RunCommand.NamedFailingType( + "The type BH.oM.Adapters.Sharepoint.Requests.OrCondition from version unknown is unknown -> data returned as custom objects.")); } [Fact] @@ -569,7 +646,8 @@ public void UnresolvableLeaf_IsCollectedSeparatelyAndNotFailed() var outer = new FakeTestResult { Status = "Error", Information = [versionSummary] }; var skips = new List(); - var result = RunCommand.ExtractFilteredResult(outer, (_, _) => (true, AttributionBasis.NotRecorded), skips); + var result = RunCommand.ExtractFilteredResult(outer, (_, _) => (true, AttributionBasis.NotRecorded), skips, + typeIndex: RunCommand.BuildLoadedTypeIndex([typeof(RunCommand).Assembly])); Assert.Equal(VersioningStatus.Pass, result.Status); Assert.Equal(0, result.FailureCount); @@ -778,7 +856,8 @@ public void CauseFromEvents_IsRecordedAsUnresolvableFromEvents() { var diagnostics = new List(); RunCommand.ExtractFilteredResult(Tree("BH.oM.Adapters.File.FileSettings", RevitCause), - (_, _) => (true, AttributionBasis.NotRecorded), null, null, diagnostics); + (_, _) => (true, AttributionBasis.NotRecorded), null, null, diagnostics, + typeIndex: RunCommand.BuildLoadedTypeIndex([typeof(RunCommand).Assembly])); var only = Assert.Single(diagnostics); Assert.False(only.CountedAsReal); @@ -849,7 +928,8 @@ public void EveryAttributedFailure_GetsExactlyOneDiagnostic() var versionSummary = new FakeTestResult { Status = "Error", Information = [leafReal, leafUnverified] }; var outer = new FakeTestResult { Status = "Error", Information = [versionSummary] }; - var result = RunCommand.ExtractFilteredResult(outer, (_, _) => (true, AttributionBasis.NotRecorded), skips, null, diagnostics); + var result = RunCommand.ExtractFilteredResult(outer, (_, _) => (true, AttributionBasis.NotRecorded), skips, null, diagnostics, + typeIndex: RunCommand.BuildLoadedTypeIndex([typeof(RunCommand).Assembly])); Assert.Equal(1, result.FailureCount); Assert.Single(skips); diff --git a/tools/VersioningRunner/src/VersioningRunner/Commands/RunCommand.cs b/tools/VersioningRunner/src/VersioningRunner/Commands/RunCommand.cs index 95d27cb..55f0481 100644 --- a/tools/VersioningRunner/src/VersioningRunner/Commands/RunCommand.cs +++ b/tools/VersioningRunner/src/VersioningRunner/Commands/RunCommand.cs @@ -83,6 +83,11 @@ public static int Execute( var subjectNamespaces = BuildSubjectNamespaces(loaded, subjectFileNames); + // Every type and namespace actually present, built once. This is what separates a + // record the closure could never have resolved from a type this PR removed, and it + // is independent of attribution: it asks what exists here, not whose it is. + var typeIndex = BuildLoadedTypeIndex(loaded); + // Attribution answers "is this failure ours"; classification answers "is it real". // Those are separate questions and this is the place the first one is answered, which // is why the declaring assembly is taken into account here rather than being used to @@ -155,7 +160,7 @@ public static int Execute( return 1; } - var partial = ExtractFilteredResult(rawResult, isAttributable, unresolvableSkips, probeSignature, diagnostics, closure); + var partial = ExtractFilteredResult(rawResult, isAttributable, unresolvableSkips, probeSignature, diagnostics, closure, typeIndex); allFailures.AddRange(partial.Failures); } @@ -254,7 +259,8 @@ public static int Execute( VerifyEntryPoints: methods.Count, SubjectAssemblies: s_subjectAssemblyCount, SubjectTypes: s_subjectTypeCount, - DatasetVersions: testAll ? 0 : 1); + DatasetVersions: testAll ? 0 : 1, + RecordsUnverified: unresolvableSkips.Count); var result = new VersioningResult { @@ -271,11 +277,17 @@ public static int Execute( // Leads with the surface actually examined. An earlier version led with the entry-point // count, which is one per repository, so a coverage line intended to make a green // interpretable read "1" and undermined the field's whole purpose. + // + // The unverified count is on the same line rather than a separate warning, because the + // two numbers are only meaningful together: 1191 subject types checked reads as a clean + // sweep until you know 145 records were not verified. Stated even when zero, so a real + // clean sweep is distinguishable from one nobody counted. Console.WriteLine( $"Coverage: {coverage.SubjectTypes} subject type(s) across {coverage.SubjectAssemblies} subject assembl(ies), " + $"checked against {(testAll ? "all staged dataset versions" : "the previous dataset version")}; " + $"{coverage.LoadedAssemblies} assembl(ies) loaded; " + - $"{coverage.VerifyEntryPoints} FromJsonDatasets entry point(s) invoked"); + $"{coverage.VerifyEntryPoints} FromJsonDatasets entry point(s) invoked; " + + $"{coverage.RecordsUnverified} record(s) attributed but not verified"); if (configuration is not null) Console.WriteLine($"Configuration: {configuration}"); @@ -439,7 +451,8 @@ public static VersioningResult ExtractFilteredResult(object? rawResult, List (IsFromLoadedNamespace(d, nsPrefixes), AttributionBasis.NotRecorded)); + rawResult, (d, _) => (IsFromLoadedNamespace(d, nsPrefixes), AttributionBasis.NotRecorded), + typeIndex: BuildLoadedTypeIndex(loaded)); } public static VersioningResult ExtractFilteredResult( @@ -448,7 +461,10 @@ public static VersioningResult ExtractFilteredResult( List? unresolvableSkips = null, Func Candidates)>? probeSignature = null, List? diagnostics = null, - ClosureContext? closure = null) + ClosureContext? closure = null, + // Defaults to Empty so a caller that is not exercising resolution keeps the + // pre-existing behaviour rather than having findings reclassified underneath it. + LoadedTypeIndex? typeIndex = null) { if (rawResult is null) return new VersioningResult @@ -459,7 +475,7 @@ public static VersioningResult ExtractFilteredResult( }; var failures = new List(); - CollectLeafFailures(rawResult, isAttributable, failures, unresolvableSkips, probeSignature, diagnostics, closure, depth: 0); + CollectLeafFailures(rawResult, isAttributable, failures, unresolvableSkips, probeSignature, diagnostics, closure, typeIndex ?? LoadedTypeIndex.Empty, depth: 0); var status = failures.Count > 0 ? VersioningStatus.Error : VersioningStatus.Pass; return new VersioningResult @@ -476,7 +492,8 @@ private static void CollectLeafFailures( List failures, List? unresolvableSkips, Func Candidates)>? probeSignature, - List? diagnostics, ClosureContext? closure, int depth) + List? diagnostics, ClosureContext? closure, + LoadedTypeIndex typeIndex, int depth) { // BHoM's TestResult tree has at most 3 levels under the root (outer → per-version // summary → individual type result). Depth 5 gives headroom for unexpected nesting @@ -541,8 +558,12 @@ private static void CollectLeafFailures( ? $"{eventType}.{eventMethod}" : desc; - string? cause = ClassifyUnresolvableCause(eventMessages); - var path = ClassificationPath.UnresolvableFromEvents; + var (cause, causeFromPayload) = ClassifyUnresolvableCause(eventMessages, typeIndex); + // The payload path reaches the same conclusion as the signature path but for a + // different reason, and the artefact needs to tell them apart. + var path = causeFromPayload + ? ClassificationPath.UnresolvableTypeAbsent + : ClassificationPath.UnresolvableFromEvents; IReadOnlyList candidates = Array.Empty(); // No type-level cause recorded means the blocker may be in the signature @@ -638,7 +659,7 @@ private static void CollectLeafFailures( { string childStatus = child.GetType().GetProperty("Status")?.GetValue(child)?.ToString() ?? "Pass"; if (childStatus is "Error" or "Warning") - CollectLeafFailures(child, isAttributable, failures, unresolvableSkips, probeSignature, diagnostics, closure, depth + 1); + CollectLeafFailures(child, isAttributable, failures, unresolvableSkips, probeSignature, diagnostics, closure, typeIndex, depth + 1); } } } @@ -1165,40 +1186,181 @@ internal static (string? Cause, ClassificationPath Path) ClassifyDeclaringTypeFa } // A leaf failure carries its cause in the EventMessages FromJsonItem attached to it, - // one per type that could not be deserialised, e.g. - // "Type Autodesk.Revit.DB.Document, RevitAPI, Version=9.0.0.0, ... failed to deserialise." - // followed by "Method from { ...json... } failed to deserialise." - // When every named type is outside BHoM the failure says nothing about this repo's - // code: RevitAPI is mixed-mode native and cannot be loaded on .NET 5+ at all, so those - // methods can never resolve in CI regardless of the PR. Returns the first such type, - // or null when the failure is real. Anything naming a BH. type is real by default, so - // a genuine regression is never classified away. - public static string? ClassifyUnresolvableCause(IEnumerable eventMessages) + // one per type that could not be deserialised. Three shapes occur, all verbatim from + // real runs: + // + // method path, from the signature: + // "Type Autodesk.Revit.DB.Document, RevitAPI, Version=9.0.0.0, ... failed to deserialise." + // object path, from the payload, at any nesting depth: + // "Failed to convert the string into a type: BH.oM.Adapters.GSA.MaterialFragments.Fabric" + // "The type BH.oM.Adapters.GSA.MaterialFragments.Fabric from version 9.2 is unknown -> data returned as custom objects." + // + // Only the first shape was parsed before, which is why every object-record failure + // reached the caller with no cause at all. The second and third are emitted by + // BHoM_Engine's deserialiser (Deprecate.cs) for a nested type as readily as a + // top-level one, so a record poisoned by a fragment from a downstream toolkit names + // that fragment here. + // + // The verdict then turns on the loaded set, not on the name: + // + // present in the loaded set -> not a blocker, ignore it + // absent, but its namespace IS loaded -> an assembly here owns that namespace and the + // type is gone. A REMOVAL. Real, and must gate. + // absent, and its namespace too -> nothing in this closure could have supplied + // it. Unverifiable, and says nothing about the + // code under test. + // + // This replaces a `t.StartsWith("BH.")` test that could not work. Its intent was "could + // CI ever load this", and for RevitAPI the proxy held. It cannot hold generally, because + // every downstream BHoM toolkit type also starts with BH.: measured on BHoM/BHoM run + // 33849699768, all 145 findings named a BH. type and all 145 were closure artefacts. + // Worse, the test discarded a cause the function had already computed correctly one line + // above, which is the same discard-at-the-handoff fault as BHoM/internal-tickets#31. + // + // A genuine removal is still caught, and deliberately so: its namespace survives in the + // loaded assembly the type was deleted from, so it takes the middle branch. Where a + // record names both a removal and a closure gap the removal wins, because losing a real + // regression is the worse error and the removed type has its own record besides. + // + // An empty index means the caller is not exercising resolution; decline rather than + // guess, so nothing is reclassified on absent evidence. + // FromPayload distinguishes the shape that produced the cause, so the caller can record + // UnresolvableTypeAbsent (payload) separately from UnresolvableFromEvents (signature). + // Keyed on the shape rather than on whether a Method event is present, because a + // signature blocker does not always come with one. + public static (string? Cause, bool FromPayload) ClassifyUnresolvableCause( + IEnumerable eventMessages, LoadedTypeIndex loaded) { - var namedTypes = new List(); + var named = new List<(string Type, bool FromPayload)>(); foreach (string message in eventMessages) { - if (string.IsNullOrEmpty(message) - || !message.StartsWith("Type ", StringComparison.Ordinal) - || !message.Contains("failed to deserialise", StringComparison.Ordinal)) + string? bare = NamedFailingType(message, out bool fromPayload); + if (bare is not null) + named.Add((bare, fromPayload)); + } + + if (named.Count == 0 || loaded.IsEmpty) + return (null, false); + + (string Type, bool FromPayload)? unresolvable = null; + + foreach (var (type, fromPayload) in named) + { + if (loaded.TypeFullNames.Contains(type)) continue; - string identity = message["Type ".Length..]; - int commaIdx = identity.IndexOf(','); - string bare = (commaIdx > 0 ? identity[..commaIdx] : identity).Trim(); + int dot = type.LastIndexOf('.'); + string ns = dot > 0 ? type[..dot] : string.Empty; - if (bare.Length > 0) - namedTypes.Add(bare); + // Namespace present, type gone: a removal. Outranks anything else here. + if (ns.Length > 0 && loaded.Namespaces.Contains(ns)) + return (null, false); + + unresolvable ??= (type, fromPayload); } - if (namedTypes.Count == 0) + return unresolvable is null ? (null, false) : (unresolvable.Value.Type, unresolvable.Value.FromPayload); + } + + // The three event shapes that name a type which failed to resolve. Returns the bare + // full name, or null when the message is not one of them. fromPayload is true for the + // two shapes the deserialiser emits about the record's own contents, false for the + // signature shape. + internal static string? NamedFailingType(string message) => NamedFailingType(message, out _); + + internal static string? NamedFailingType(string message, out bool fromPayload) + { + fromPayload = false; + if (string.IsNullOrWhiteSpace(message)) return null; - if (namedTypes.Any(t => t.StartsWith("BH.", StringComparison.Ordinal))) + const string convertPrefix = "Failed to convert the string into a type: "; + const string unknownPrefix = "The type "; + const string unknownMarker = " is unknown -> data returned as custom objects."; + + string raw; + if (message.StartsWith("Type ", StringComparison.Ordinal) + && message.Contains("failed to deserialise", StringComparison.Ordinal)) + { + raw = message["Type ".Length..]; + } + else if (message.StartsWith(convertPrefix, StringComparison.Ordinal)) + { + raw = message[convertPrefix.Length..]; + fromPayload = true; + } + else if (message.StartsWith(unknownPrefix, StringComparison.Ordinal) + && message.EndsWith(unknownMarker, StringComparison.Ordinal)) + { + // "The type X from version 9.2 is unknown -> ...". The version clause is + // always present and always follows the type, so cut at " from version ". + raw = message[unknownPrefix.Length..^unknownMarker.Length]; + int fromIdx = raw.LastIndexOf(" from version ", StringComparison.Ordinal); + if (fromIdx > 0) + raw = raw[..fromIdx]; + fromPayload = true; + } + else + { return null; + } + + // Identities may be assembly-qualified; the comparison downstream is on the bare + // full name because that is what the loaded index holds. + int commaIdx = raw.IndexOf(','); + string bare = (commaIdx > 0 ? raw[..commaIdx] : raw).Trim(); + return bare.Length > 0 ? bare : null; + } + + // Every type and every exact namespace present in the loaded assemblies. + // + // Reflection over a loaded assembly can throw per-type (the same + // ReflectionTypeLoadException shape BuildSubjectNamespaces guards against), so a type + // that cannot be read is skipped rather than losing its whole assembly. A skipped type + // is a type this index does not know about, which biases towards "absent", which biases + // towards leaving a finding real. That is the safe direction. + public static LoadedTypeIndex BuildLoadedTypeIndex(IEnumerable assemblies) + { + var typeNames = new HashSet(StringComparer.Ordinal); + var namespaces = new HashSet(StringComparer.Ordinal); + + foreach (var asm in assemblies) + { + Type?[] types; + try + { + types = asm.GetTypes(); + } + catch (ReflectionTypeLoadException ex) + { + types = ex.Types; + } + catch + { + continue; + } + + foreach (var type in types) + { + if (type is null) + continue; + + try + { + if (type.FullName is { Length: > 0 } full) + typeNames.Add(full); + if (type.Namespace is { Length: > 0 } ns) + namespaces.Add(ns); + } + catch + { + // Unreadable type; see the note above on why skipping is the safe bias. + } + } + } - return namedTypes[0]; + return new LoadedTypeIndex(typeNames, namespaces); } // Classifies an exception thrown from invoking a versioning test method. diff --git a/tools/VersioningRunner/src/VersioningRunner/Models/VersioningResult.cs b/tools/VersioningRunner/src/VersioningRunner/Models/VersioningResult.cs index df93f5b..27e1e94 100644 --- a/tools/VersioningRunner/src/VersioningRunner/Models/VersioningResult.cs +++ b/tools/VersioningRunner/src/VersioningRunner/Models/VersioningResult.cs @@ -46,6 +46,40 @@ public enum ClassificationPath // one the subject did build (Revit_Core_Engine_2024 against a Release/2022 build), so // the code exists in the repo and simply was not compiled in this run. ConfigurationNotBuilt, + + // A type the record needs is absent from the loaded set, and so is its whole + // namespace, so no assembly in this closure could have supplied it. The record + // cannot be verified here and says nothing about the code under test. + // + // Distinct from UnresolvableFromEvents (the same conclusion reached on the method + // path) and from NoMethodEvent (an attribution problem, not a resolution one), so the + // artefact separates "could not resolve it" from "could not attribute it". + UnresolvableTypeAbsent, +} + +// The types and namespaces actually present in the loaded assemblies. +// +// This is the evidence that separates a closure gap from a genuine removal, and it is the +// only thing available that can. A type absent from the loaded set whose namespace IS +// loaded means an assembly here owns that namespace and the type is gone: a removal, and +// real. A type absent whose namespace is absent too means nothing in this closure could +// have supplied it: unverifiable, and not a statement about the code under test. +// +// Namespaces are exact, not prefixes. The name arrives from an event message as a full +// type name, so its namespace is known exactly rather than inferred, which is why this can +// be a set-membership test where attribution cannot. +public sealed record LoadedTypeIndex( + HashSet TypeFullNames, + HashSet Namespaces) +{ + // Nothing loaded and nothing known. Used by callers not exercising resolution; the + // classifier declines to decide rather than guessing, so an absent index cannot + // silently reclassify anything. + public static LoadedTypeIndex Empty { get; } = new( + new HashSet(StringComparer.Ordinal), + new HashSet(StringComparer.Ordinal)); + + public bool IsEmpty => TypeFullNames.Count == 0 && Namespaces.Count == 0; } // Which evidence decided that a failure belongs to the subject repository. @@ -126,7 +160,12 @@ public record CoverageCounts( int VerifyEntryPoints, int SubjectAssemblies, int SubjectTypes, - int DatasetVersions); + int DatasetVersions, + // Records attributed to this repository that could not be verified, for any reason. + // Present so a green cannot be read as a clean sweep of SubjectTypes: a run reporting + // 1191 types checked and 145 records unverified has not examined what the first number + // implies. Counts records, not types, because that is the unit the dataset iterates. + int RecordsUnverified = 0); public class VersioningResult {