Skip to content
Merged
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
7 changes: 7 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,13 @@ jobs:
run: dotnet run --project ClaudeRevit.CliTests -c Release
- name: ModPlus adapter checks (isolated Revit contracts, no installed ModPlus)
run: dotnet run --project ClaudeRevit.ModPlusTests -c Release
- name: Headless bindings and call-graph checks (no vendor algorithms executed)
shell: pwsh
run: |
dotnet build ClaudeRevit.HeadlessTests/Fixtures/GuardFixtures.csproj -c Release
if ($LASTEXITCODE -ne 0) { throw "Guard fixtures failed to build" }
dotnet run --project ClaudeRevit.HeadlessTests -c Release -- ClaudeRevit/bin/Release/ClaudeRevit.dll ClaudeRevit.HeadlessTests/Fixtures/bin/Release/net10.0/mprHeadlessFixtures.dll ClaudeRevit/Standards/ModPlusHeadless/bindings.json
if ($LASTEXITCODE -ne 0) { throw "Headless checks failed" }
- name: Compile installer detection check
uses: Minionguyjpro/Inno-Setup-Action@v1.2.2
with:
Expand Down
8 changes: 8 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,14 @@ jobs:
}
Get-ChildItem "ClaudeRevit-$ver-Revit*.zip" | Format-List Name, Length

- name: Headless adapter checks against the packaged Revit 2027 DLL
shell: pwsh
run: |
dotnet build ClaudeRevit.HeadlessTests/Fixtures/GuardFixtures.csproj -c Release
if ($LASTEXITCODE -ne 0) { throw "Guard fixtures failed to build" }
dotnet run --project ClaudeRevit.HeadlessTests -c Release -- installer/payload/2027/ClaudeRevit.dll ClaudeRevit.HeadlessTests/Fixtures/bin/Release/net10.0/mprHeadlessFixtures.dll ClaudeRevit/Standards/ModPlusHeadless/bindings.json
if ($LASTEXITCODE -ne 0) { throw "Headless checks failed" }

# Refuse to silently move a published release tag: a manual dispatch may only (re)tag
# a version that doesn't exist yet, or one that already points at this very commit.
- name: Guard against retagging an existing release (manual dispatch only)
Expand Down
4 changes: 4 additions & 0 deletions ClaudeRevit.HeadlessTests/ClaudeRevit.HeadlessTests.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup><OutputType>Exe</OutputType><TargetFramework>net10.0-windows</TargetFramework><UseWPF>true</UseWPF><Nullable>disable</Nullable><ImplicitUsings>disable</ImplicitUsings><EnableDefaultCompileItems>false</EnableDefaultCompileItems></PropertyGroup>
<ItemGroup><Compile Include="Program.cs" /></ItemGroup>
</Project>
44 changes: 44 additions & 0 deletions ClaudeRevit.HeadlessTests/Fixtures/GuardFixtures.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
using System;
using System.Collections.Generic;
using System.Reflection;
using System.Runtime.InteropServices;
using System.Threading.Tasks;

namespace System.Windows
{
public static class MessageBox { public static void Show(string value) => throw new Exception("A GUI fixture must never execute."); }
public class Window { public virtual void Show() => throw new Exception("A GUI fixture must never execute."); }
}

namespace mprHeadlessFixtures
{
public static class Algorithms
{
public static int Sum(int a, int b) => a + b;
public static void DirectUi() => System.Windows.MessageBox.Show("dialog");
private static void Indirect() => DirectUi();
public static void TransitiveUi() => Indirect();
public static object NewWindow() => new System.Windows.Window();
public static IEnumerable<int> DeferredUi() { System.Windows.MessageBox.Show("dialog"); yield return 1; }
public static async Task<int> AsyncOperation() { await Task.Yield(); return 1; }
public static object ReflectiveCall(MethodInfo method) => method.Invoke(null, null);
public static void DelegateCall(Action action) => action();
public static int VirtualCall(Base value) => value.Calculate();
public static int StaticFieldCall() => UiInitializer.Value;
public static object LateUi() => new LateUiEnumerable();
[DllImport("user32.dll")] public static extern bool MessageBeep(uint type);
}
public class Base { public virtual int Calculate() => 1; }
public class Derived : Base { public override int Calculate() { System.Windows.MessageBox.Show("dialog"); return 1; } }
public static class UiInitializer { public static readonly int Value; static UiInitializer() { System.Windows.MessageBox.Show("dialog"); Value = 1; } }
public sealed class LateUiEnumerable : System.Collections.IEnumerable
{
public System.Collections.IEnumerator GetEnumerator() => new LateUiIterator();
}
public sealed class LateUiIterator : System.Collections.IEnumerator
{
public object Current { get { System.Windows.MessageBox.Show("dialog"); return 1; } }
public bool MoveNext() => true;
public void Reset() { }
}
}
11 changes: 11 additions & 0 deletions ClaudeRevit.HeadlessTests/Fixtures/GuardFixtures.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<AssemblyName>mprHeadlessFixtures</AssemblyName>
<EnableDefaultCompileItems>false</EnableDefaultCompileItems>
<Nullable>disable</Nullable>
<ImplicitUsings>disable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup><Compile Include="GuardFixtures.cs" /></ItemGroup>
</Project>
106 changes: 106 additions & 0 deletions ClaudeRevit.HeadlessTests/Program.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Runtime.Loader;
using System.Text.Json;

internal static class ManagedTests
{
private static int passed;
private static void Check(bool success, string description)
{
if (!success) throw new Exception("FAILED: " + description);
passed++;
}
public static void Main(string[] args)
{
Assembly library = AssemblyLoadContext.Default.LoadFromAssemblyPath(Path.GetFullPath(args[0]));
Assembly fixture = AssemblyLoadContext.Default.LoadFromAssemblyPath(Path.GetFullPath(args[1]));
Type guard = library.GetType("ModPlus.Headless.UiCallGuard", true);
object instance = Activator.CreateInstance(guard, BindingFlags.Instance | BindingFlags.NonPublic, null, new object[] { fixture }, null);
MethodInfo inspect = guard.GetMethod("Check", BindingFlags.Instance | BindingFlags.NonPublic);
Type functions = fixture.GetType("mprHeadlessFixtures.Algorithms", true);
Check((bool)inspect.Invoke(instance, new object[] { functions.GetMethod("Sum") }) == false, "pure function allowed");
Check((bool)inspect.Invoke(instance, new object[] { functions.GetMethod("Sum") }) == false, "safe cache remains valid");
foreach (string name in new[] { "DirectUi", "TransitiveUi", "NewWindow", "DeferredUi", "AsyncOperation", "ReflectiveCall", "MessageBeep", "DelegateCall", "VirtualCall", "StaticFieldCall" })
{
for (int attempt = 0; attempt < 2; attempt++)
{
bool rejected = false;
try { inspect.Invoke(instance, new object[] { functions.GetMethod(name) }); }
catch (TargetInvocationException e) { rejected = e.InnerException?.GetType().FullName == "ModPlus.Headless.HeadlessBindingException"; }
Check(rejected, name + ": rejected on attempt " + attempt);
}
}
object unsafeEnumerable = Activator.CreateInstance(fixture.GetType("mprHeadlessFixtures.LateUiEnumerable", true));
MethodInfo enumerate = guard.GetMethod("Enumerate", BindingFlags.Instance | BindingFlags.NonPublic);
var deferred = (IEnumerable<object>)enumerate.Invoke(instance, new[] { unsafeEnumerable });
bool lateRejected = false;
try { deferred.ToArray(); } catch (Exception e) { lateRejected = e.GetType().FullName == "ModPlus.Headless.HeadlessBindingException"; }
Check(lateRejected, "actual iterator Current body checked before iteration");
Type catalogType = library.GetType("ModPlus.Headless.BindingCatalog", true);
object catalog = Activator.CreateInstance(catalogType, File.ReadAllText(args[2]));
MethodInfo moduleMethod = catalogType.GetMethod("Module");
MethodInfo typeMethod = catalogType.GetMethod("Type");
JsonElement module = (JsonElement)moduleMethod.Invoke(catalog, new object[] { "mprCopyAlongCurve" });
Check(module.GetProperty("available").GetBoolean(), "available module lookup");
JsonElement method = module.GetProperty("methods").EnumerateArray().Single(m => m.GetProperty("name").GetString() == "Divide");
Check(method.GetProperty("parameters")[1].GetProperty("name").GetString() == "spacing", "original parameter name retained");
Check(method.GetProperty("parameters")[3].GetProperty("type").GetString().Contains("Nullable", StringComparison.Ordinal), "nullable signature retained");
JsonElement absent = (JsonElement)moduleMethod.Invoke(catalog, new object[] { "mprCopyParameterValues" });
Check(!absent.GetProperty("available").GetBoolean(), "missing 2027 module stays unavailable");
bool missingRejected = false;
try { moduleMethod.Invoke(catalog, new object[] { "../outside" }); }
catch (TargetInvocationException e) { missingRejected = e.InnerException?.GetType().FullName == "ModPlus.Headless.HeadlessBindingException"; }
Check(missingRejected, "unknown/traversal module rejected");
using JsonDocument source = JsonDocument.Parse(File.ReadAllText(args[2]));
JsonElement shared = source.RootElement.GetProperty("shared")[0].GetProperty("types").EnumerateArray().First();
JsonElement result = (JsonElement)typeMethod.Invoke(catalog, new object[] { "mprCopyAlongCurve", shared.GetProperty("id").GetString() });
Check(result.GetProperty("assembly").GetString() == "ModPlusAPI", "shared DTO lookup retains assembly identity");
JsonElement[] available = source.RootElement.GetProperty("modules").EnumerateArray().Where(m => m.GetProperty("available").GetBoolean()).ToArray();
string previousBindings = Environment.GetEnvironmentVariable("CLAUDEREVIT_MODPLUS_BINDINGS");
try
{
Environment.SetEnvironmentVariable("CLAUDEREVIT_MODPLUS_BINDINGS", null);
object embedded = catalogType.GetMethod("FromEmbedded").Invoke(null, null);
var modules = (IEnumerable<JsonElement>)catalogType.GetProperty("Modules").GetValue(embedded);
Check(modules.Count() == 85, "all 85 module contracts embedded in production assembly");
JsonElement embeddedModule = (JsonElement)moduleMethod.Invoke(embedded, new object[] { "mprCopyAlongCurve" });
Check(embeddedModule.GetRawText() == module.GetRawText(), "embedded snapshot matches distributed definitions");
}
finally { Environment.SetEnvironmentVariable("CLAUDEREVIT_MODPLUS_BINDINGS", previousBindings); }
Check(available.Length == 83, "all remaining 83 modules included");
Check(available.All(m => m.GetProperty("methods").GetArrayLength() > 0), "every module has core bindings");
Check(available.SelectMany(m => m.GetProperty("methods").EnumerateArray()).Select(m => m.GetProperty("id").GetString()).Distinct().Count() == available.Sum(m => m.GetProperty("methods").GetArrayLength()), "binding IDs unique");
Check(available.Any(m => m.GetProperty("types").EnumerateArray().Any(t => t.GetProperty("properties").GetArrayLength() > 0)), "writable DTO property schemas included");
Type contracts = library.GetType("ModPlus.Headless.NativeContracts", true);
MethodInfo forModule = contracts.GetMethod("ForModule", BindingFlags.Public | BindingFlags.Static);
Check(source.RootElement.GetProperty("modules").EnumerateArray().Sum(m => ((IEnumerable<JsonElement>)forModule.Invoke(null, new object[] { m.GetProperty("id").GetString() })).Count()) == 32, "all 32 native contracts described");
MethodInfo validate = contracts.GetMethod("Validate", BindingFlags.Static | BindingFlags.NonPublic);
void Validate(string moduleId, string operation, string input)
{
using var document = JsonDocument.Parse(input);
validate.Invoke(null, new object[] { moduleId, operation, document.RootElement });
}
Validate("mprPhases", "list_phases", "{}");
Validate("mprParameters", "set_parameters", "{\"element_ids\":[1],\"values\":[{\"parameter\":{\"built_in\":\"ALL_MODEL_INSTANCE_COMMENTS\"},\"value\":\"Hello\"},{\"parameter\":{\"guid\":\"91f8d025-5a67-4026-9ccf-b7882336b74a\"},\"value\":{\"value\":250,\"unit\":\"mm\"}}]}");
Check(true, "native read and typed parameter contracts accepted");
foreach (var invalid in new[]
{
("mprPhases", "list_phases", "{\"element_ids\":[1]}"),
("mprVisibility", "set_element_visibility", "{\"view_id\":1,\"element_ids\":[],\"hidden\":true}"),
("mprParameters", "set_parameters", "{\"element_ids\":[1],\"values\":[{\"parameter\":{\"name\":\"X\",\"guid\":\"x\"},\"value\":5}]}"),
("mprAlignViews", "align_viewport_centers", "{\"reference_viewport_id\":1,\"element_ids\":[2],\"axis\":\"z\"}"),
("mprNotes", "create_text_note", "{\"view_id\":1,\"text_type_id\":2,\"point_mm\":[0,0],\"text\":\"X\"}")
})
{
bool rejected = false;
try { Validate(invalid.Item1, invalid.Item2, invalid.Item3); }
catch (TargetInvocationException e) { rejected = e.InnerException is ArgumentException; }
Check(rejected, "malformed native request rejected: " + invalid.Item2);
}
Console.WriteLine(JsonSerializer.Serialize(new { passed, scope = "Managed metadata, call-graph rejection and failed-cache regression. No live model execution." }));
}
}
6 changes: 5 additions & 1 deletion ClaudeRevit.McpTests/Stubs.cs
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,11 @@ public static class ToolPolicy
{
public static bool IsEnabled(TestTool tool, IReadOnlyCollection<string>? disabledGroups = null)
{
if (tool.RequiresCodeExecutionOptIn) return ClaudeRevit.Services.SettingsStore.AllowCodeExecution;
if (tool.RequiresCodeExecutionOptIn)
{
if (!ClaudeRevit.Services.SettingsStore.AllowCodeExecution) return false;
if (!tool.Name.StartsWith("modplus_mpr", StringComparison.Ordinal) && tool.Name != "run_modplus_headless") return true;
}
disabledGroups ??= ClaudeRevit.Services.SettingsStore.DisabledToolGroups;
return disabledGroups.Count == 0 || !disabledGroups.Contains(ToolCatalog.CategoryOf(tool), StringComparer.OrdinalIgnoreCase);
}
Expand Down
4 changes: 4 additions & 0 deletions ClaudeRevit.ModPlusTests/ClaudeRevit.ModPlusTests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@
<PackageReference Include="Anthropic" Version="12.53.0" />
<Compile Include="../ClaudeRevit/Tools/ModPlusTools.cs" Link="src/ModPlusTools.cs" />
<Compile Include="../ClaudeRevit/Tools/IRevitTool.cs" Link="src/IRevitTool.cs" />
<Compile Include="../ClaudeRevit/Tools/ToolPolicy.cs" Link="src/ToolPolicy.cs" />
<Compile Include="../ClaudeRevit/Tools/ToolCatalog.cs" Link="src/ToolCatalog.cs" />
<Compile Include="../ClaudeRevit/Services/ToolSearchLogic.cs" Link="src/ToolSearchLogic.cs" />
<Compile Include="../ClaudeRevit/Tools/DynamicToolProxy.cs" Link="src/DynamicToolProxy.cs" />
<Compile Include="../ClaudeRevit/Tools/ToolInput.cs" Link="src/ToolInput.cs" />
<Compile Include="../ClaudeRevit/Tools/ToolInputException.cs" Link="src/ToolInputException.cs" />
<Compile Include="../ClaudeRevit/Services/ModPlusCatalog.cs" Link="src/ModPlusCatalog.cs" />
Expand Down
20 changes: 20 additions & 0 deletions ClaudeRevit.ModPlusTests/Contracts.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// unchanged. These contracts never ship in the add-in and cannot execute ModPlus assemblies.
using System.Text.Json;
using Anthropic.Models.Beta.Messages;
using Autodesk.Revit.UI;
namespace Autodesk.Revit.DB
{
public sealed record ElementId(long Value);
Expand Down Expand Up @@ -56,10 +57,29 @@ public class RibbonPanel
}
namespace ClaudeRevit.Services
{
public static class SettingsStore
{
public static bool AllowCodeExecution = true;
public static IReadOnlyCollection<string> DisabledToolGroups = Array.Empty<string>();
}
public static class DocumentSessions { public static bool Same(Autodesk.Revit.DB.Document? a, Autodesk.Revit.DB.Document? b) => a != null && ReferenceEquals(a, b); }
}
namespace ClaudeRevit.Tools
{
public sealed class ForwardingFixture : IRevitTool
{
public string Name { get; init; } = "modplus_mprnumbering";
public string Description => "fixture";
public InputSchema InputSchema => new() { Properties = new Dictionary<string, JsonElement>() };
public bool RequiresTransaction => false;
public bool MutatesWithoutTransaction => true;
public bool RequiresNoTurnGroup => true;
public bool InvalidatesCatalog => true;
public bool IsScriptTool => true;
public int PreflightCalls;
public void Preflight(IReadOnlyDictionary<string, JsonElement> input, UIApplication app) => PreflightCalls++;
public string Execute(IReadOnlyDictionary<string, JsonElement> input, UIApplication app) => "{}";
}
public static class ToolDispatcher { public static bool ForceSuppress; }
public static class ToolContext
{
Expand Down
18 changes: 17 additions & 1 deletion ClaudeRevit.ModPlusTests/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,22 @@
try
{
var app=new UIApplication();
var inner = new ForwardingFixture();
IRevitTool proxy = new DynamicToolProxy(inner);
Check(proxy.RequiresNoTurnGroup && !proxy.RequiresTransaction && proxy.MutatesWithoutTransaction, "proxy forwards transaction ownership");
Check(proxy.IsScriptTool && proxy.InvalidatesCatalog, "proxy forwards cache and journal flags");
Check(proxy.RequiresCodeExecutionOptIn && proxy.RequiresConfirmation, "proxy retains mandatory code gate");
proxy.Preflight(Args("{}"), app);
Check(inner.PreflightCalls == 1, "proxy runs inner preflight");
Check(ToolPolicy.IsEnabled(proxy), "enabled headless alias");
Check(!ToolPolicy.IsEnabled(proxy, ["ModPlus headless"]), "alias group can be disabled");
IRevitTool router = new DynamicToolProxy(new ForwardingFixture { Name = "run_modplus_headless" });
Check(!ToolPolicy.IsEnabled(router, ["ModPlus"]), "router group can be disabled");
SettingsStore.AllowCodeExecution = false;
Check(!ToolPolicy.IsEnabled(proxy) && !ToolPolicy.IsEnabled(router), "code switch denies alias and router");
Check(ToolPolicy.IsEnabled(new ModPlusSumParameters()), "fixed read-only sum independent of code switch");
SettingsStore.AllowCodeExecution = true;
Check(ToolPolicy.IsEnabled(new DynamicToolProxy(new ForwardingFixture { Name = "custom_code" }), ["ModPlus"]), "ordinary custom-code group behavior retained");
var panel=new RibbonPanel();
var pull=new PulldownButton();
pull.Items.Add(new PushButton { Name="Sum", ItemText="Сумма параметров", ClassName="mprSumParameters.Command" });
Expand Down Expand Up @@ -69,4 +85,4 @@
Check(app.ActiveUIDocument.Selection.GetElementIds().Single().Value==2,"selection passed to posted command");
Console.WriteLine(JsonSerializer.Serialize(new { checks, status="passed", nativeRevitExecution="not tested", scope="production tools with isolated Revit contracts and real Anthropic schemas" }));
}
finally { Environment.SetEnvironmentVariable("CLAUDEREVIT_MODPLUS_DATA",previous); ToolContext.Bound=null; ToolDispatcher.ForceSuppress=false; if(Directory.Exists(data))Directory.Delete(data,true); }
finally { Environment.SetEnvironmentVariable("CLAUDEREVIT_MODPLUS_DATA",previous); ToolContext.Bound=null; ToolDispatcher.ForceSuppress=false; SettingsStore.AllowCodeExecution=true; if(Directory.Exists(data))Directory.Delete(data,true); }
1 change: 1 addition & 0 deletions ClaudeRevit.Tests/ClaudeRevit.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
<Compile Include="..\ClaudeRevit\Services\NormRules.cs" Link="src\NormRules.cs" />
<Compile Include="..\ClaudeRevit\Services\ModPlusCatalog.cs" Link="src\ModPlusCatalog.cs" />
<Compile Include="..\ClaudeRevit\Services\ModPlusTotals.cs" Link="src\ModPlusTotals.cs" />
<Compile Include="..\ClaudeRevit\Services\HeadlessResultLimits.cs" Link="src\HeadlessResultLimits.cs" />
<EmbeddedResource Include="..\ClaudeRevit\Standards\ModPlus2027.json" LogicalName="ClaudeRevit.Standards.ModPlus2027.json" />
<Compile Include="..\ClaudeRevit\Services\TepCalculator.cs" Link="src\TepCalculator.cs" />
<Compile Include="..\ClaudeRevit\Services\PdfDrawing.cs" Link="src\PdfDrawing.cs" />
Expand Down
Loading
Loading