From ce08098a3b59e2151c5f3753a2ffae6bbcf38cd3 Mon Sep 17 00:00:00 2001 From: Seun Akanni Date: Sat, 12 Sep 2026 19:07:12 +0100 Subject: [PATCH] feat(versioning): surface the declaring assembly into the test result Object records carry only a type name, so the checker downstream decides ownership by matching the namespace prefix against the repository under test. Namespaces are shared and no rule over the name separates the owners at any precision. The dataset's `_asm` field records the assembly that declared the type at capture; this surfaces it so the checker can read it. The checker sees this TestResult and never the dataset file, so an event on the failure path is the only channel. The wording is a contract with CI_Toolkit's VersioningRunner, which parses it in ParseObjectEventAssembly: Object declared in ", " Read from the raw json rather than the deserialised object, because the serialiser skips every field whose name begins with an underscore and because this runs where there is often no object left to ask. The reader is a depth-aware scan rather than a regex, so an identically named field on a nested fragment cannot be mistaken for the record's own, and rather than a JSON library, which would mean a new dependency for one field read. Emitted only on the failure path, only when the field is present, and never for method records: their declaring assembly already reaches the checker through the Method event, `_asm` is prohibited on Methods.json, and a method record's top-level type is System.Reflection.MethodBase, so emitting one would state something untrue. DescriptionFromJson is not touched. Its positional splits are unchanged. --- .../Helpers/TopLevelFieldFromJson.cs | 132 ++++++++++++++++++ .ci/code/Versioning_Test/Verify/FromJson.cs | 55 ++++++-- 2 files changed, 179 insertions(+), 8 deletions(-) create mode 100644 .ci/code/Versioning_Test/Helpers/TopLevelFieldFromJson.cs diff --git a/.ci/code/Versioning_Test/Helpers/TopLevelFieldFromJson.cs b/.ci/code/Versioning_Test/Helpers/TopLevelFieldFromJson.cs new file mode 100644 index 0000000..68da6cc --- /dev/null +++ b/.ci/code/Versioning_Test/Helpers/TopLevelFieldFromJson.cs @@ -0,0 +1,132 @@ +/* + * This file is part of the Buildings and Habitats object Model (BHoM) + * Copyright (c) 2015 - 2026, the respective contributors. All rights reserved. + * + * Each contributor holds copyright over their respective contributions. + * The project versioning (Git) records all such contribution source information. + * + * + * The BHoM is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3.0 of the License, or + * (at your option) any later version. + * + * The BHoM is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this code. If not, see . + */ + + +using System.Text; + +namespace BH.Test.Versioning +{ + public static partial class Helpers + { + /*************************************/ + /**** Public Methods ****/ + /*************************************/ + + // Reads a top-level field from a serialised record. Depth-aware so an identically named + // field on a nested fragment is not mistaken for the record's own. + public static string TopLevelFieldFromJson(string json, string field) + { + if (string.IsNullOrEmpty(json) || string.IsNullOrEmpty(field)) + return null; + + int depth = 0; + bool inString = false; + bool escaped = false; + int stringStart = -1; + + for (int i = 0; i < json.Length; i++) + { + char c = json[i]; + + if (inString) + { + if (escaped) + escaped = false; + else if (c == '\\') + escaped = true; + else if (c == '"') + { + inString = false; + + // A string is a key only when a colon follows it; a value never is. + bool isWantedName = i - stringStart - 1 == field.Length + && string.CompareOrdinal(json, stringStart + 1, field, 0, field.Length) == 0; + + if (depth == 1 && isWantedName) + { + int j = i + 1; + while (j < json.Length && (json[j] == ' ' || json[j] == '\t')) + j++; + + if (j < json.Length && json[j] == ':') + return StringValueAt(json, j + 1); + } + } + + continue; + } + + if (c == '"') + { + inString = true; + escaped = false; + stringStart = i; + } + else if (c == '{' || c == '[') + depth++; + else if (c == '}' || c == ']') + depth--; + } + + return null; + } + + /*************************************/ + /**** Private Methods ****/ + /*************************************/ + + private static string StringValueAt(string json, int from) + { + int i = from; + while (i < json.Length && (json[i] == ' ' || json[i] == '\t')) + i++; + + // Present but not a string. Returning null leaves the caller on its existing path. + if (i >= json.Length || json[i] != '"') + return null; + + StringBuilder value = new StringBuilder(); + bool escaped = false; + + for (i++; i < json.Length; i++) + { + char c = json[i]; + + if (escaped) + { + value.Append(c); + escaped = false; + } + else if (c == '\\') + escaped = true; + else if (c == '"') + return value.ToString(); + else + value.Append(c); + } + + return null; + } + + /*************************************/ + } +} diff --git a/.ci/code/Versioning_Test/Verify/FromJson.cs b/.ci/code/Versioning_Test/Verify/FromJson.cs index ebc508a..f56a67b 100644 --- a/.ci/code/Versioning_Test/Verify/FromJson.cs +++ b/.ci/code/Versioning_Test/Verify/FromJson.cs @@ -245,14 +245,53 @@ public static TestResult FromJsonItem(string json, bool isMethod) if (message == "") return Engine.Test.Create.PassResult(description); - else - return new TestResult - { - Description = description, - Status = TestStatus.Error, - Message = message, - Information = Engine.Base.Query.CurrentEvents().Select(x => x.ToEventMessage()).ToList() - }; + + List information = Engine.Base.Query.CurrentEvents().Select(x => x.ToEventMessage()).ToList(); + + ITestInformation provenance = ProvenanceEvent(json, isMethod); + if (provenance != null) + information.Add(provenance); + + return new TestResult + { + Description = description, + Status = TestStatus.Error, + Message = message, + Information = information + }; + } + + /*************************************/ + + // Surfaces the record's declaring assembly for the checker, which reads this TestResult + // and never the dataset file. The wording is a contract with CI_Toolkit's VersioningRunner: + // + // Object declared in "" + // + // The quoted part holds the assembly alone because a closed generic type name contains + // commas, so a comma-delimited form cannot represent one. + private static ITestInformation ProvenanceEvent(string json, bool isMethod) + { + // A method record already carries its declaring assembly, and its top-level type is + // System.Reflection.MethodBase, so emitting this for one would state something untrue. + if (isMethod) + return null; + + string declaringAssembly = Helpers.TopLevelFieldFromJson(json, "_asm"); + if (string.IsNullOrWhiteSpace(declaringAssembly)) + return null; + + string declaringType = Helpers.TopLevelFieldFromJson(json, "_t"); + if (string.IsNullOrWhiteSpace(declaringType)) + return null; + + return new EventMessage + { + // Not a failure. Note this does not hide it from FullMessage, which defaults to + // minSeverity Pass. + Status = TestStatus.Pass, + Message = $"Object {declaringType} declared in \"{declaringAssembly}\"" + }; } /*************************************/