Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions .github/actions/ci-versioning/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,54 @@ runs:
dotnet restore "${{ steps.solution.outputs.path }}"
dotnet build "${{ steps.solution.outputs.path }}" --no-restore -c Release --nologo -m -clp:ErrorsOnly

# Build the repo's own Release* alternate configurations as well.
#
# The versioning datasets record the assembly that declared each method, and on Revit
# repos that is a year-suffixed assembly per configuration: Revit_Core_Engine_2022
# through _2026. A Release-only build produces one of them, so every dataset entry
# naming another cannot be evaluated at all. BHoMBot does not have this problem
# because CloneInstaller.cs:23 runs BuildConfigs over the installer's altConfigs.txt
# before versioning, so its ProgramData carries every configuration.
#
# Measured on a sandbox Revit_Toolkit, windows-2025-vs2026, n=3: the first
# configuration takes 47-59s and carries the whole warm-up, each additional one takes
# 4-8s (median 5), and Build\ grows 1.2 MB per configuration. Total 76s against a 47s
# Release-only baseline, so 1.6x rather than the 5x a per-config estimate suggests.
#
# Debug* entries are skipped: only Release* ships, matching the installer. On
# Revit_Toolkit, Release2022 duplicates plain Release (same REVIT2022 constant, same
# assembly name). It is left in rather than special-cased, because detecting that
# requires reading DefineConstants and the saving is six seconds.
$altFile = Join-Path "${{ github.workspace }}" 'altConfigs.txt'
if (Test-Path $altFile) {
$repo = "${{ github.repository }}"
$configs = Get-Content $altFile |
ForEach-Object { $_.Trim() } |
Where-Object { $_ } |
ForEach-Object {
# Lines are 'org/repo/ConfigName'. Some files list other repos, and building
# those here would apply their configuration name to this source tree.
$parts = $_.Split('/')
if ($parts.Count -ge 3 -and "$($parts[0])/$($parts[1])" -eq $repo) { $parts[2] }
} |
Where-Object { $_ -like 'Release*' } |
Select-Object -Unique

if ($configs) {
Write-Host "::notice title=Versioning::Building $($configs.Count) alternate configuration(s): $($configs -join ', ')."
foreach ($c in $configs) {
dotnet build "${{ steps.solution.outputs.path }}" --no-restore -c $c --nologo -m -clp:ErrorsOnly
if ($LASTEXITCODE -ne 0) {
# Loud, not soft. A configuration that will not compile is a build problem, and
# continuing silently would put us back to judging methods whose declaring
# assembly was never produced, which is the defect this exists to remove.
Write-Host "::error title=Versioning::Configuration '$c' failed to build."
exit 1
}
}
}
}

# There is deliberately no build-completeness fast-fail here any more.
#
# A "Fast-fail on missing versioning-critical DLLs" step used to download
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
using VersioningRunner.Commands;
using VersioningRunner.Models;
using VersioningRunner.Tests.Fixtures;
using Xunit;

namespace VersioningRunner.Tests
{
// The reclassification rule is easy to get subtly wrong and had no coverage at first:
// no test calls RunCommand.Execute, so the static state v1 relied on was never populated
// and the rule could not fire in any test. The three tests v1 broke therefore passed
// again under its gate incidentally, not because the gate was verified. These arrange
// the closure explicitly so the rule is actually exercised.
public class ClosureReclassificationTests
{
private const string ModelQaEvent =
"Method TryGetValueFromSource from { \"_t\" : \"System.Type\", \"Name\" : \"BH.Revit.Engine.Core.Compute, Revit_ModelQA_Engine_2022, Version=9.0.0.0, Culture=neutral, PublicKeyToken=null\", \"_bhomVersion\" : \"9.2\" } failed to deserialise.";

private const string Config2024Event =
"Method ProjectParameter from { \"_t\" : \"System.Type\", \"Name\" : \"BH.Revit.Engine.Core.Create, Revit_Core_Engine_2024, Version=9.0.0.0, Culture=neutral, PublicKeyToken=null\", \"_bhomVersion\" : \"9.2\" } failed to deserialise.";

private const string SubjectAsmEvent =
"Method Gone from { \"_t\" : \"System.Type\", \"Name\" : \"BH.Revit.Engine.Core.Compute, Revit_Core_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] }]
};
}

private static ClosureContext Closure(string[] loaded, string[] subject)
{
var l = new HashSet<string>(loaded, StringComparer.Ordinal);
return new ClosureContext(
l,
new HashSet<string>(l.Select(RunCommand.StripConfigSuffix), StringComparer.Ordinal),
new HashSet<string>(subject.Select(RunCommand.StripConfigSuffix), StringComparer.Ordinal));
}

// A non-empty candidate list is the runner's record that some OTHER loaded assembly
// answered for the type.
private static Func<string, string, string?, (string?, ClassificationPath, IReadOnlyList<string>)> Answered(
params string[] answering)
=> (_, _, _) => (null, ClassificationPath.SignatureResolved, answering);

private static readonly Func<string, string, string?, (string?, ClassificationPath, IReadOnlyList<string>)> NothingAnswered =
(_, _, _) => (null, ClassificationPath.DeclaringTypeNotLoaded, Array.Empty<string>());

private static (VersioningResult Result, FailureDiagnostic Diag) Run(
FakeTestResult tree,
Func<string, string, string?, (string?, ClassificationPath, IReadOnlyList<string>)> probe,
ClosureContext? closure)
{
var diagnostics = new List<FailureDiagnostic>();
var result = RunCommand.ExtractFilteredResult(
tree, _ => true, new List<RunCommand.UnverifiedFailure>(),
(t, m, a) => probe(t, m, a), diagnostics, closure);
return (result, Assert.Single(diagnostics));
}

[Fact]
public void ForeignAssemblyAnsweredByAnother_IsUnverified()
{
var (result, d) = Run(
Tree("BH.Revit.Engine.Core.Compute.TryGetValueFromSource", ModelQaEvent),
Answered("Revit_Core_Engine_2022"),
Closure(loaded: ["Revit_Core_Engine_2022"], subject: ["Revit_Core_Engine_2022", "Revit_oM"]));

Assert.Equal(0, result.FailureCount);
Assert.False(d.CountedAsReal);
Assert.Equal(ClassificationPath.ForeignDeclaringAssembly, d.Path);
}

[Fact]
public void ConfigurationVariantNotBuilt_IsUnverified()
{
var (result, d) = Run(
Tree("BH.Revit.Engine.Core.Create.ProjectParameter", Config2024Event),
Answered("Revit_Core_Engine_2022"),
Closure(loaded: ["Revit_Core_Engine_2022"], subject: ["Revit_Core_Engine_2022"]));

Assert.Equal(0, result.FailureCount);
Assert.False(d.CountedAsReal);
Assert.Equal(ClassificationPath.ConfigurationNotBuilt, d.Path);
}

// The v1 defect, guarded. With no answering assembly the path is
// DeclaringTypeNotLoaded, which is how a genuinely removed type presents. v1 had no
// candidates precondition and relabelled this as foreign, turning a real removal
// into a silent pass.
[Fact]
public void AbsentAssemblyAndNothingAnswered_StaysReal()
{
var (result, d) = Run(
Tree("BH.Revit.Engine.Core.Compute.Gone", SubjectAsmEvent),
NothingAnswered,
Closure(loaded: ["Revit_oM"], subject: ["Revit_oM"]));

Assert.Equal(1, result.FailureCount);
Assert.True(d.CountedAsReal);
Assert.Equal(ClassificationPath.DeclaringTypeNotLoaded, d.Path);
}

[Fact]
public void DeclaringAssemblyPresent_IsUntouched()
{
var (result, d) = Run(
Tree("BH.Revit.Engine.Core.Create.ProjectParameter", Config2024Event),
Answered("Revit_Core_Engine_2024"),
Closure(loaded: ["Revit_Core_Engine_2024", "Revit_Core_Engine_2022"], subject: ["Revit_Core_Engine_2022"]));

Assert.Equal(1, result.FailureCount);
Assert.True(d.CountedAsReal);
Assert.Equal(ClassificationPath.SignatureResolved, d.Path);
}

[Fact]
public void NoClosureSupplied_IsUntouched()
{
var (result, d) = Run(
Tree("BH.Revit.Engine.Core.Compute.TryGetValueFromSource", ModelQaEvent),
Answered("Revit_Core_Engine_2022"),
closure: null);

Assert.Equal(1, result.FailureCount);
Assert.True(d.CountedAsReal);
}

// The whole family is gone, not just one configuration of it. Nothing distinguishes
// that from a deliberate removal, so it must not be excused.
[Fact]
public void SubjectFamilyWithNoLoadedVariant_StaysReal()
{
var (result, d) = Run(
Tree("BH.Revit.Engine.Core.Create.ProjectParameter", Config2024Event),
Answered("Revit_oM"),
Closure(loaded: ["Revit_oM"], subject: ["Revit_Core_Engine_2022", "Revit_oM"]));

Assert.Equal(1, result.FailureCount);
Assert.True(d.CountedAsReal);
Assert.Equal(ClassificationPath.SignatureResolved, d.Path);
}

[Fact]
public void AlreadyUnverified_IsNotRelabelled()
{
var (result, d) = Run(
Tree("BH.Revit.Engine.Core.Compute.TryGetValueFromSource", ModelQaEvent),
(_, _, _) => ("RevitAPI", ClassificationPath.SignatureBlockerOutsideBHoM, new[] { "Revit_Core_Engine_2022" }),
Closure(loaded: ["Revit_Core_Engine_2022"], subject: ["Revit_Core_Engine_2022"]));

Assert.Equal(0, result.FailureCount);
Assert.False(d.CountedAsReal);
Assert.Equal(ClassificationPath.SignatureBlockerOutsideBHoM, d.Path);
Assert.Equal("RevitAPI", d.Cause);
}

[Theory]
[InlineData("Revit_Core_Engine_2024", "Revit_Core_Engine")]
[InlineData("Revit_Core_Engine", "Revit_Core_Engine")]
[InlineData("Structure_oM", "Structure_oM")]
// Documents a known limitation: the heuristic cannot tell a Revit release year from
// any other four-digit 20xx suffix. No such assembly exists in the fleet today
// (measured: 295 of 640 match, all prefixed Revit), but nothing enforces that.
[InlineData("Eurocode_2004", "Eurocode")]
[InlineData("Foo_1999", "Foo_1999")]
public void StripConfigSuffix_CollapsesOnlyA20xxTail(string input, string expected)
=> Assert.Equal(expected, RunCommand.StripConfigSuffix(input));

// Documents current behaviour and a residual risk: the declaring assembly is taken
// from the FIRST parsable Method event, so a finding carrying events for several
// assemblies is decided by the first. A present assembly later in the list does not
// stop the finding being excused.
[Fact]
public void MultipleMethodEvents_TheFirstNamedAssemblyDecides()
{
var (result, d) = Run(
Tree("BH.Revit.Engine.Core.Compute.TryGetValueFromSource", ModelQaEvent, SubjectAsmEvent),
Answered("Revit_Core_Engine_2022"),
Closure(loaded: ["Revit_Core_Engine_2022"], subject: ["Revit_Core_Engine_2022"]));

Assert.Equal("Revit_ModelQA_Engine_2022", d.DeclaringAssembly);
Assert.Equal(0, result.FailureCount);
Assert.Equal(ClassificationPath.ForeignDeclaringAssembly, d.Path);
}
}
}
Loading
Loading