From dbdac4315c401d1ca14e23a5d11b9dffa149b9b2 Mon Sep 17 00:00:00 2001 From: Seun Akanni Date: Sat, 12 Sep 2026 18:33:21 +0100 Subject: [PATCH] feat(ci-versioning): read the declaring assembly from object records Object records carry a type name and nothing else, so attribution has always had to guess ownership from the namespace prefix. Namespaces are shared, so that guess cannot be made correct at any precision. The dataset's new `_asm` field closes the gap; this is the runner half that consumes it. Three changes: - `ParseObjectEventAssembly` reads the declaring assembly and type from a new event shape, consulted only when the Method event yielded nothing so the method path is untouched. - `ProbeTypeCandidates` collects every loaded assembly declaring a given type, so an object record whose type is declared more than once is counted by the existing ambiguity metric instead of being normalised away silently. - No change to year handling. `StripConfigSuffix` already collapses the Revit `_20NN` suffix and `IsFromSubjectAssembly` already compares on it, so this adds tests rather than code. Inert until Versioning_Toolkit emits the event: the runner's only view of the dataset is the TestResult tree, so with nothing emitting, every path is unchanged. The existing 135 tests pass unmodified. --- tools/VersioningRunner/bhom-subject-list.txt | 0 .../ObjectRecordProvenanceTests.cs | 356 ++++++++++++++++++ .../VersioningRunner/Commands/RunCommand.cs | 115 +++++- 3 files changed, 465 insertions(+), 6 deletions(-) create mode 100644 tools/VersioningRunner/bhom-subject-list.txt create mode 100644 tools/VersioningRunner/src/VersioningRunner.Tests/ObjectRecordProvenanceTests.cs diff --git a/tools/VersioningRunner/bhom-subject-list.txt b/tools/VersioningRunner/bhom-subject-list.txt new file mode 100644 index 0000000..e69de29 diff --git a/tools/VersioningRunner/src/VersioningRunner.Tests/ObjectRecordProvenanceTests.cs b/tools/VersioningRunner/src/VersioningRunner.Tests/ObjectRecordProvenanceTests.cs new file mode 100644 index 0000000..d963270 --- /dev/null +++ b/tools/VersioningRunner/src/VersioningRunner.Tests/ObjectRecordProvenanceTests.cs @@ -0,0 +1,356 @@ +using System.Reflection; +using VersioningRunner.Commands; +using VersioningRunner.Models; +using VersioningRunner.Tests.Fixtures; +using Xunit; + +namespace VersioningRunner.Tests +{ + // Object records carry a type name and nothing else, so attribution has always guessed from + // the namespace. The dataset's `_asm` field closes that, and these pin the runner half. + // + // Every test here drives a synthetic TestResult, because the runner has no other view of the + // dataset and Versioning_Toolkit does not emit the event yet. That is the limit of this + // coverage: it proves the runner handles the format it defines, not that anything produces it. + public class ObjectRecordProvenanceTests + { + // The contract Versioning_Toolkit's FromJson.cs is bound to emit. + private static string ObjectEvent(string type, string assembly) => + $"Object {type} declared in \"{assembly}\""; + + // A closed generic. Its name contains commas, which is why the quoted part holds the + // assembly alone: a comma-delimited form silently lost all 23 of these in the 9.2 + // dataset. This one is the case that mattered most, because its declaring assembly is + // another repository's while its namespace is the subject's, so losing the field left + // the original misattribution in place. + private const string ClosedGeneric = + "BH.oM.Structure.Results.ResultEnvelope`1[[BH.oM.Structure.Results.ConnectionForce, StructuralEngineering_oM, Version=9.0.0.0, Culture=neutral, PublicKeyToken=null]]"; + + private const string MethodEvent = + "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."; + + private static FakeTestResult Tree(string description, params string[] events) + { + var leaf = new FakeTestInfo + { + Status = "Error", + Description = description, + Message = "Error: Returned null from json.", + Information = events.Select(m => (object)new FakeEventMessage { Message = m }).ToList() + }; + return new FakeTestResult + { + Status = "Error", + Information = [new FakeTestResult { Status = "Error", Information = [leaf] }] + }; + } + + // Subject names arrive as file names with extension, exactly as ReadSubjectAssemblyListFrom + // produces them, so the extension handling is exercised rather than assumed away. + private static ClosureContext ClosureForSubject(params string[] subjectFileNames) + { + var loaded = new HashSet(StringComparer.Ordinal); + return new ClosureContext( + loaded, + new HashSet(loaded.Select(RunCommand.StripConfigSuffix), StringComparer.Ordinal), + new HashSet( + RunCommand.ReadSubjectAssemblyListFrom(subjectFileNames) + .Select(f => RunCommand.StripConfigSuffix(Path.GetFileNameWithoutExtension(f))), + StringComparer.Ordinal)); + } + + // ------------------------------------------------------------------ + // Reading the field + // ------------------------------------------------------------------ + + [Fact] + public void ObjectEvent_YieldsTheDeclaringAssemblyAndType() + { + var (type, assembly) = RunCommand.ParseObjectEventAssembly( + ObjectEvent("BH.oM.Acoustic.Panel", "Acoustic_oM")); + + Assert.Equal("BH.oM.Acoustic.Panel", type); + Assert.Equal("Acoustic_oM", assembly); + } + + [Fact] + public void DeclaringAssemblyFromAnObjectEvent_ReachesTheDiagnostic() + { + var diagnostics = new List(); + RunCommand.ExtractFilteredResult( + Tree("BH.oM.Acoustic.Panel", ObjectEvent("BH.oM.Acoustic.Panel", "Acoustic_oM")), + (_, _) => (true, AttributionBasis.NotRecorded), null, null, diagnostics); + + Assert.Equal("Acoustic_oM", Assert.Single(diagnostics).DeclaringAssembly); + } + + // The whole point of the field: this record used to be attributable only by prefix. + [Fact] + public void ObjectRecordNamingTheSubjectsAssembly_IsAttributedByDeclaringAssembly() + { + var closure = ClosureForSubject("Acoustic_oM.dll"); + + var (attributable, basis) = RunCommand.AttributeToSubject( + "BH.oM.Acoustic.Panel", "Acoustic_oM", new HashSet(StringComparer.Ordinal), closure); + + Assert.True(attributable); + Assert.Equal(AttributionBasis.DeclaringAssembly, basis); + } + + [Fact] + public void ObjectRecordNamingAnotherRepositorysAssembly_IsNotAttributed() + { + var closure = ClosureForSubject("Acoustic_oM.dll"); + + var (attributable, basis) = RunCommand.AttributeToSubject( + "BH.oM.Structure.Elements.Panel", "Structure_oM", new HashSet(StringComparer.Ordinal), closure); + + Assert.False(attributable); + Assert.Equal(AttributionBasis.DeclaringAssembly, basis); + } + + // ------------------------------------------------------------------ + // Inertness. These are the tests that say this PR does nothing on its own. + // ------------------------------------------------------------------ + + [Fact] + public void NoObjectEvent_LeavesTheDeclaringAssemblyNull() + { + var diagnostics = new List(); + RunCommand.ExtractFilteredResult( + Tree("BH.oM.Acoustic.Panel", "Failed to convert the string into a type: BH.oM.Acoustic.Panel"), + (_, _) => (true, AttributionBasis.NotRecorded), null, null, diagnostics); + + Assert.Null(Assert.Single(diagnostics).DeclaringAssembly); + } + + // A record cannot be both, and the method path must not change. If both events are + // present the Method event still wins, because it is read first and the object read is + // only consulted when it yielded nothing. + [Fact] + public void MethodEventStillWins_WhenBothArePresent() + { + var diagnostics = new List(); + RunCommand.ExtractFilteredResult( + Tree("BH.Revit.Engine.MechanicalPlumbing.Compute. }", + MethodEvent, + ObjectEvent("BH.oM.Acoustic.Panel", "Acoustic_oM")), + (_, _) => (true, AttributionBasis.NotRecorded), null, + (_, _, _) => (null, ClassificationPath.DeclaringTypeNotLoaded, Array.Empty()), diagnostics); + + Assert.Equal("Revit_MechanicalPlumbing_Engine_2022", Assert.Single(diagnostics).DeclaringAssembly); + } + + // Whole closure discards the declaring assembly for attribution: Execute wires + // `(d, _) =>` and supplies no ClosureContext. So on a run with no subject list this + // change cannot alter which findings are reported, only what the artefact records. + // The backfill's own validation runs were whole-closure, so they could not have + // exercised any of the attribution behaviour above. + [Fact] + public void WholeClosure_IgnoresTheDeclaringAssemblyForAttribution() + { + var nsPrefixes = new HashSet(["BH.oM.Acoustic"], StringComparer.Ordinal); + Func wholeClosure = + (d, _) => (RunCommand.IsFromLoadedNamespace(d, nsPrefixes), AttributionBasis.NotRecorded); + + var withAsm = new List(); + RunCommand.ExtractFilteredResult( + Tree("BH.oM.Acoustic.Panel", ObjectEvent("BH.oM.Acoustic.Panel", "Somebody_Elses_oM")), + wholeClosure, null, null, withAsm); + + Assert.Equal(AttributionBasis.NotRecorded, Assert.Single(withAsm).AttributedBy); + Assert.True(withAsm[0].CountedAsReal); + } + + // ------------------------------------------------------------------ + // The Revit year. The backfill wrote an arbitrary lowest year on 158 records, and this + // is what makes that harmless rather than a silent misattribution. + // ------------------------------------------------------------------ + + [Theory] + [InlineData("Revit_ModelQA_oM_2022")] + [InlineData("Revit_ModelQA_oM_2023")] + [InlineData("Revit_ModelQA_oM_2024")] + [InlineData("Revit_ModelQA_oM_2025")] + [InlineData("Revit_ModelQA_oM_2026")] + public void AnyYearAttributesToASubjectBuildingAnyOtherYear(string recordedAssembly) + { + // The subject built Release2024, so infer-verification-config staged exactly one year. + var closure = ClosureForSubject("Revit_ModelQA_oM_2024.dll"); + + Assert.True(RunCommand.IsFromSubjectAssembly(recordedAssembly, closure), + $"{recordedAssembly} must attribute to a Release2024 build: all five year variants are " + + "one repository, so the year carries no information about ownership"); + } + + // "More precise" would be wrong here, and this is the shape of the regression it would + // cause: an exact-year match fails in four configurations out of five and the finding is + // dropped as another repository's. Recorded as a test so nobody reintroduces it. + [Fact] + public void TheYearIsNotComparedExactly() + { + var closure = ClosureForSubject("Revit_ModelQA_oM_2024.dll"); + + Assert.True(RunCommand.IsFromSubjectAssembly("Revit_ModelQA_oM_2022", closure)); + Assert.False(RunCommand.IsFromSubjectAssembly("Revit_Tagging_oM_2022", closure)); + } + + // StripConfigSuffix is anchored `_20\d{2}$`, so an `_asm` carrying a file extension does + // not match the anchor, passes through unchanged, and attributes to nobody. This is why + // the backfill wrote bare simple names, matching how method records already express a + // declaring assembly. Pinned so the dataset convention cannot drift without a red test. + [Fact] + public void AnAssemblyNameCarryingAnExtensionDoesNotAttribute() + { + var closure = ClosureForSubject("Acoustic_oM.dll"); + + Assert.True(RunCommand.IsFromSubjectAssembly("Acoustic_oM", closure)); + Assert.False(RunCommand.IsFromSubjectAssembly("Acoustic_oM.dll", closure)); + } + + // SubjectBaseNames is built with StringComparer.Ordinal while the file-name set upstream + // is OrdinalIgnoreCase, so the base-name comparison is case-sensitive where the name + // comparison is not. Not a defect today, because both sides come from the same build + // output, but it is a real edge and it should fail visibly if it ever starts mattering. + [Fact] + public void BaseNameComparisonIsCaseSensitive() + { + var closure = ClosureForSubject("Acoustic_oM.dll"); + + Assert.True(RunCommand.IsFromSubjectAssembly("Acoustic_oM", closure)); + Assert.False(RunCommand.IsFromSubjectAssembly("acoustic_om", closure)); + } + + // ------------------------------------------------------------------ + // Ambiguity on object records + // ------------------------------------------------------------------ + + [Fact] + public void MoreThanOneAssemblyDeclaringTheType_IsRecordedAsCandidates() + { + var diagnostics = new List(); + RunCommand.ExtractFilteredResult( + Tree("BH.oM.Acoustic.Panel", ObjectEvent("BH.oM.Acoustic.Panel", "Revit_ModelQA_oM_2022")), + (_, _) => (true, AttributionBasis.NotRecorded), null, null, diagnostics, + probeTypeCandidates: _ => ["Revit_ModelQA_oM_2022", "Revit_ModelQA_oM_2023"]); + + var only = Assert.Single(diagnostics); + Assert.NotNull(only.DeclaringTypeCandidates); + Assert.Equal(2, only.DeclaringTypeCandidates!.Count); + } + + // One candidate is the ordinary case; carrying it would add a noise row to every finding. + [Fact] + public void ASingleDeclaringAssembly_LeavesCandidatesNull() + { + var diagnostics = new List(); + RunCommand.ExtractFilteredResult( + Tree("BH.oM.Acoustic.Panel", ObjectEvent("BH.oM.Acoustic.Panel", "Acoustic_oM")), + (_, _) => (true, AttributionBasis.NotRecorded), null, null, diagnostics, + probeTypeCandidates: _ => ["Acoustic_oM"]); + + Assert.Null(Assert.Single(diagnostics).DeclaringTypeCandidates); + } + + // Without the object event there is no type to scan, so the probe is never called and + // the pre-existing behaviour stands. This is the other half of inertness. + [Fact] + public void NoObjectEvent_DoesNotScanForCandidates() + { + bool called = false; + var diagnostics = new List(); + RunCommand.ExtractFilteredResult( + Tree("BH.oM.Acoustic.Panel", "Failed to convert the string into a type: BH.oM.Acoustic.Panel"), + (_, _) => (true, AttributionBasis.NotRecorded), null, null, diagnostics, + probeTypeCandidates: _ => { called = true; return []; }); + + Assert.False(called, "a record with no object event has no type to scan and must not be probed"); + Assert.Null(Assert.Single(diagnostics).DeclaringTypeCandidates); + } + + [Fact] + public void ProbeTypeCandidates_ReturnsEveryAssemblyDeclaringTheType() + { + var loaded = new List { typeof(RunCommand).Assembly, typeof(object).Assembly }; + + Assert.Equal(["VersioningRunner"], + RunCommand.ProbeTypeCandidates(loaded, typeof(RunCommand).FullName!)); + Assert.Empty(RunCommand.ProbeTypeCandidates(loaded, "No.Such.Type")); + } + + // ------------------------------------------------------------------ + // Malformed input fails to parse rather than capturing something wrong + // ------------------------------------------------------------------ + + [Theory] + [InlineData("Object BH.oM.Acoustic.Panel declared in \"Acoustic_oM")] + [InlineData("Object declared in \"Acoustic_oM\"")] + [InlineData("BH.oM.Acoustic.Panel declared in \"Acoustic_oM\"")] + [InlineData("Object BH.oM.Acoustic.Panel declared in \"\"")] + [InlineData("")] + public void AMalformedObjectEventYieldsNothing(string message) + { + var (type, assembly) = RunCommand.ParseObjectEventAssembly(message); + + Assert.Null(type); + Assert.Null(assembly); + } + + // ------------------------------------------------------------------ + // Closed generics. The class the first version of this contract silently lost. + // ------------------------------------------------------------------ + + [Fact] + public void AClosedGenericTypeNameParses() + { + var (type, assembly) = RunCommand.ParseObjectEventAssembly( + ObjectEvent(ClosedGeneric, "StructuralEngineering_oM")); + + Assert.Equal(ClosedGeneric, type); + Assert.Equal("StructuralEngineering_oM", assembly); + } + + // The commas inside the type argument list are the whole reason the format changed. + [Fact] + public void TheTypeArgumentListDoesNotTruncateTheAssembly() + { + var (_, assembly) = RunCommand.ParseObjectEventAssembly( + ObjectEvent(ClosedGeneric, "StructuralEngineering_oM")); + + Assert.DoesNotContain(",", assembly); + Assert.DoesNotContain("Version=", assembly!); + } + + // The case that mattered: the type's namespace is the subject's, so the namespace guess + // claims it, while the declaring assembly says it is another repository's. Under the old + // format this record fell back and stayed misattributed. + [Fact] + public void AClosedGenericDeclaredElsewhereIsNotAttributedToTheSubject() + { + var closure = ClosureForSubject("Structure_oM.dll"); + var subjectNs = new HashSet(["BH.oM.Structure.Results"], StringComparer.Ordinal); + + var withField = RunCommand.AttributeToSubject( + ClosedGeneric, "StructuralEngineering_oM", subjectNs, closure); + Assert.False(withField.Attributable); + Assert.Equal(AttributionBasis.DeclaringAssembly, withField.Basis); + + // What the fallback does when the field cannot be read, which is what the earlier + // comma-delimited format produced for this record. + var withoutField = RunCommand.AttributeToSubject(ClosedGeneric, null, subjectNs, closure); + Assert.True(withoutField.Attributable); + Assert.Equal(AttributionBasis.NamespaceFallback, withoutField.Basis); + } + + [Fact] + public void AClosedGenericReachesTheDiagnosticEndToEnd() + { + var diagnostics = new List(); + RunCommand.ExtractFilteredResult( + Tree(ClosedGeneric, ObjectEvent(ClosedGeneric, "StructuralEngineering_oM")), + (_, _) => (true, AttributionBasis.NotRecorded), null, null, diagnostics); + + Assert.Equal("StructuralEngineering_oM", Assert.Single(diagnostics).DeclaringAssembly); + } + } +} diff --git a/tools/VersioningRunner/src/VersioningRunner/Commands/RunCommand.cs b/tools/VersioningRunner/src/VersioningRunner/Commands/RunCommand.cs index 0a1a324..8367896 100644 --- a/tools/VersioningRunner/src/VersioningRunner/Commands/RunCommand.cs +++ b/tools/VersioningRunner/src/VersioningRunner/Commands/RunCommand.cs @@ -134,6 +134,7 @@ public static int Execute( Func Candidates)> probeSignature = (typeFullName, methodName, declaringAssembly) => ProbeDeclaringType(loaded, typeFullName, methodName, declaringAssembly); + Func> probeTypeCandidates = t => ProbeTypeCandidates(loaded, t); foreach (var method in methods) { object? rawResult; @@ -160,7 +161,7 @@ public static int Execute( return 1; } - var partial = ExtractFilteredResult(rawResult, isAttributable, unresolvableSkips, probeSignature, diagnostics, closure, typeIndex); + var partial = ExtractFilteredResult(rawResult, isAttributable, unresolvableSkips, probeSignature, diagnostics, closure, typeIndex, probeTypeCandidates); allFailures.AddRange(partial.Failures); } @@ -490,7 +491,8 @@ public static VersioningResult ExtractFilteredResult(object? rawResult, List (IsFromLoadedNamespace(d, nsPrefixes), AttributionBasis.NotRecorded), - typeIndex: BuildLoadedTypeIndex(loaded)); + typeIndex: BuildLoadedTypeIndex(loaded), + probeTypeCandidates: t => ProbeTypeCandidates(loaded, t)); } public static VersioningResult ExtractFilteredResult( @@ -502,7 +504,10 @@ public static VersioningResult ExtractFilteredResult( 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) + LoadedTypeIndex? typeIndex = null, + // Null means object-record ambiguity is not scanned, which is the pre-existing + // behaviour and what every caller that does not supply a closure should get. + Func>? probeTypeCandidates = null) { if (rawResult is null) return new VersioningResult @@ -513,7 +518,7 @@ public static VersioningResult ExtractFilteredResult( }; var failures = new List(); - CollectLeafFailures(rawResult, isAttributable, failures, unresolvableSkips, probeSignature, diagnostics, closure, typeIndex ?? LoadedTypeIndex.Empty, depth: 0); + CollectLeafFailures(rawResult, isAttributable, failures, unresolvableSkips, probeSignature, diagnostics, closure, typeIndex ?? LoadedTypeIndex.Empty, probeTypeCandidates, depth: 0); var status = failures.Count > 0 ? VersioningStatus.Error : VersioningStatus.Pass; return new VersioningResult @@ -531,7 +536,7 @@ private static void CollectLeafFailures( List? unresolvableSkips, Func Candidates)>? probeSignature, List? diagnostics, ClosureContext? closure, - LoadedTypeIndex typeIndex, int depth) + LoadedTypeIndex typeIndex, Func>? probeTypeCandidates, 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 @@ -583,6 +588,21 @@ private static void CollectLeafFailures( .Select(ParseMethodEventAssembly) .FirstOrDefault(a => a is not null); + // Object records carry no Method event, so the assembly comes from the dataset's + // `_asm` field instead, surfaced as its own event. Consulted only when the Method + // event yielded nothing, so the method path is untouched: a record cannot be both. + // Null throughout until Versioning_Toolkit emits the event, which is what makes + // this change inert on its own. + string? objectTypeName = null; + if (declaringAssembly is null) + { + var objectEvent = eventMessages + .Select(ParseObjectEventAssembly) + .FirstOrDefault(p => p.Assembly is not null); + objectTypeName = objectEvent.TypeName; + declaringAssembly = objectEvent.Assembly; + } + var (attributable, attributedBy) = isAttributable(desc, declaringAssembly); if (!attributable) return; @@ -609,7 +629,19 @@ private static void CollectLeafFailures( if (cause is null) { if (eventType is null || eventMethod is null) + { path = ClassificationPath.NoMethodEvent; + + // An object record has no method, so the signature probe cannot run and the + // path stays NoMethodEvent. The ambiguity question is still live and is + // answerable from the type alone: if more than one loaded assembly declares + // it, `_asm` resolved to one of several and the run should say so rather + // than normalise it away silently. Without this the metric below can never + // count an object record, because candidates only ever came from the + // method probe. + if (probeTypeCandidates is not null && objectTypeName is not null) + candidates = probeTypeCandidates(objectTypeName); + } else if (probeSignature is not null) (cause, path, candidates) = probeSignature(eventType, eventMethod, declaringAssembly); else @@ -697,7 +729,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, typeIndex, depth + 1); + CollectLeafFailures(child, isAttributable, failures, unresolvableSkips, probeSignature, diagnostics, closure, typeIndex, probeTypeCandidates, depth + 1); } } } @@ -989,6 +1021,77 @@ public static (string? DeclaringType, string? MethodName) ParseMethodEvent(strin return assembly.Length > 0 ? assembly : null; } + // Object records have no Method event, so until now they had no declaring assembly at all + // and always fell to the namespace guess. The dataset's `_asm` field closes that, and this + // is the wire format it arrives in: + // + // Object declared in "" + // + // THIS IS A CONTRACT. Versioning_Toolkit's FromJson.cs must emit exactly this shape for the + // field to be read; nothing else in the runner can see the dataset. + // + // The quoted part holds the assembly ALONE, and that is a correction rather than a + // preference. This first mirrored the Method event's assembly-qualified "Name", + // `", "`, and that shape cannot represent the data: a closed generic + // type name contains commas, e.g. + // + // BH.oM.Structure.Results.ResultEnvelope`1[[BH.oM.Structure.Results.ConnectionForce, + // StructuralEngineering_oM, Version=...]] + // + // so a comma-delimited group stopped at the first argument and the match failed. Measured + // over the 9.2 dataset, the earlier shape parsed 1,690 of 1,713 records and **silently lost + // 23**, each falling back to the namespace guess with no diagnostic. One of the 23 is + // attributed to the wrong repository by that fallback, which is the exact defect this field + // exists to remove. An assembly simple name can never contain a quote, so the assembly-only + // form is unambiguous and parses 1,713 of 1,713. + // + // The type is still carried, in the leading position, where it needs no delimiter. + private static readonly Regex _objectEventAssemblyPattern = new( + @"^Object\s+(?.+?)\s+declared\s+in\s+""(?[^""]+)""$", + RegexOptions.Compiled); + + // The declaring type is returned alongside the assembly because the ambiguity scan needs a + // type name and the leaf's Description is not reliably one: DescriptionFromJson mangles + // some entries. The event states it directly. + public static (string? TypeName, string? Assembly) ParseObjectEventAssembly(string message) + { + if (string.IsNullOrEmpty(message)) + return (null, null); + + var match = _objectEventAssemblyPattern.Match(message); + if (!match.Success) + return (null, null); + + string type = match.Groups["type"].Value.Trim(); + string assembly = match.Groups["assembly"].Value.Trim(); + return (type.Length > 0 ? type : null, assembly.Length > 0 ? assembly : null); + } + + // Every loaded assembly that yields the named type. The type-only half of + // ProbeDeclaringType's candidate collection, for records that have no method to probe. + // + // Not shared with ProbeDeclaringType, deliberately. That loop interleaves the signature + // probe with the candidate walk and takes its verdict from the first assembly that + // answers; splitting it would give the method path a second pass over the closure and + // change code this PR has no reason to touch. + internal static IReadOnlyList ProbeTypeCandidates(List loaded, string typeFullName) + { + var candidates = new List(); + foreach (var asm in loaded) + { + Type? type; + try { type = asm.GetType(typeFullName, throwOnError: false); } + catch { continue; } + + if (type is null) + continue; + + try { candidates.Add(asm.GetName().Name ?? "(unnamed)"); } + catch { candidates.Add("(unnamed)"); } + } + return candidates; + } + // A failure attributed to the subject that could not actually be verified, and the // non-BHoM type identity that made it unverifiable. public readonly record struct UnverifiedFailure(string Description, string Cause);