BREAKING: 3-tier split step 2 + engine-bridge v3 EgBim lambdas wired

Completes the Generic / HmEG-aware / App-specific separation started in
f6b6e74. The legacy EgPlugin / EngineBridge / EngineBridge.Client /
EngineBridge.Probe modules are moved into their proper tiers, namespaces
and csproj/sln entries are renamed, and the HmegDirectStateProvider
lambdas are finally populated with real handles from the EgBim plugin
host. A new Recordingtest.Architecture.Tests project enforces the tier
rule at build time.

Moves (git mv + csproj/RootNamespace/AssemblyName rename + sln):

  src/Recordingtest.EgPlugin
    -> src/Sut/EgBim/Recordingtest.Sut.EgBim.PluginHost
  src/Recordingtest.EngineBridge
    -> src/Hmeg/Recordingtest.Hmeg.Catalog
  src/Recordingtest.EngineBridge.Client
    -> src/Hmeg/Recordingtest.Hmeg.Bridge.Client
  src/Recordingtest.EngineBridge.Probe
    -> src/Hmeg/Recordingtest.Hmeg.Catalog.Probe

  tests/Recordingtest.EgPlugin.Tests
    -> tests/Sut/EgBim/Recordingtest.Sut.EgBim.PluginHost.Tests
  tests/Recordingtest.EngineBridge.Tests
    -> tests/Hmeg/Recordingtest.Hmeg.Catalog.Tests
  tests/Recordingtest.EngineBridge.IntegrationTests
    -> tests/Hmeg/Recordingtest.Hmeg.Catalog.IntegrationTests

Namespace rename applied across all .cs files and csproj RootNamespace:

  Recordingtest.EgPlugin           -> Recordingtest.Sut.EgBim.PluginHost
  Recordingtest.EngineBridge       -> Recordingtest.Hmeg.Catalog
  Recordingtest.EngineBridge.Client -> Recordingtest.Hmeg.Bridge.Client
  Recordingtest.EngineBridge.Probe -> Recordingtest.Hmeg.Catalog.Probe

New: tests/Recordingtest.Architecture.Tests/

  DependencyGraphTests walks Assembly.GetReferencedAssemblies() for each
  tier and fails if a forbidden reference leaks in:
    - Generic modules must not reference HmEG or any app-specific DLL
    - HmEG-aware modules must not reference app-specific DLLs
    - Recordingtest.Hmeg.Bridge must reference HmEG (positive check)
  11 tests, all passing. Prevents future drift from CLAUDE.md §8.1.

Engine-bridge v3 wire-up (HmEgBridgePlugin.BuildProvider):

  Previously the HmegDirectStateProvider lambdas returned null and the
  chain fell through to reflection. They now call directly into the
  EditorPlugin base class that HmEgBridgePlugin inherits:

    spaceProvider    = () => RootSpace
                           // AppManager.ViewportManager.RootSpace
    viewportProvider = () => View
                           // EGViewport : Control, HmEGViewport
    documentPathProvider = () => AppManager?.FileManager?.CurrentFile

  Every lambda is wrapped in try/catch so plugin construction still
  cannot throw back into the SUT. Editor02.HmEGAppManager.dll added as
  a reference on Recordingtest.Sut.EgBim.PluginHost.csproj — app-
  specific tier, which is allowed by the architecture tests.

Entry points were confirmed from read-only review of the SUT sources at
  D:\GiteaAll\EG-BIM_Modeler\EditorPluginInterface\EditorPlugin.cs
  D:\GiteaAll\EG-BIM_Modeler\HmEGApplicationManagementLibrary\HmEGAppManager.cs
  D:\GiteaAll\EG-BIM_Modeler\HmEGApplicationManagementLibrary\SubManager\FileManager.cs

closing out Q1/Q2/Q6/Q7 from docs/hmeg-api-survey.md.

Tests: 115 -> 126 (+11 Architecture), 0 failures.

Next step: live verification of /scene /camera /selection with a real
SUT session; any discrepancy in HmegDirectStateProvider reflection will
be tightened after observing real HmEG camera field names.

Ref: #10 follow-up, #14 follow-up, docs/contracts/generic-sut-split.md.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
minsung
2026-04-09 10:39:13 +09:00
parent f6b6e7449e
commit 03fb504eea
36 changed files with 542 additions and 206 deletions

View File

@@ -0,0 +1,130 @@
using System.Reflection;
using System.Text;
namespace Recordingtest.Hmeg.Catalog;
public sealed record Candidate(
string Category,
string Assembly,
string TypeName,
string MemberKind,
string MemberName,
string Signature);
public static class CandidateFinder
{
private static readonly (string Category, string[] Keywords)[] Categories =
{
("select", new[] { "Selection", "Selected", "Picked", "Pick" }),
("camera", new[] { "Camera", "Viewport", "EyePoint", "LookAt", "View" }),
("scene", new[] { "Scene", "Document", "World", "Root" }),
("render", new[] { "Render", "Draw", "Frame", "Dirty" }),
};
public static IReadOnlyList<Candidate> Find(IEnumerable<TypeInfo> types)
{
var results = new List<Candidate>();
foreach (var type in types)
{
var typeName = type.FullName ?? type.Name;
var asmName = type.Assembly.GetName().Name ?? "?";
foreach (var (category, keywords) in Categories)
{
bool typeMatches = keywords.Any(k =>
typeName.Contains(k, StringComparison.OrdinalIgnoreCase));
// Properties
PropertyInfo[] props;
try { props = type.GetProperties(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static | BindingFlags.DeclaredOnly); }
catch { props = Array.Empty<PropertyInfo>(); }
foreach (var p in props)
{
if (typeMatches || keywords.Any(k => p.Name.Contains(k, StringComparison.OrdinalIgnoreCase)))
{
results.Add(new Candidate(
category, asmName, typeName, "Property", p.Name,
SafePropertySignature(p)));
}
}
// Methods
MethodInfo[] methods;
try { methods = type.GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static | BindingFlags.DeclaredOnly); }
catch { methods = Array.Empty<MethodInfo>(); }
foreach (var m in methods)
{
if (m.IsSpecialName) continue; // skip property/event accessors
if (typeMatches || keywords.Any(k => m.Name.Contains(k, StringComparison.OrdinalIgnoreCase)))
{
results.Add(new Candidate(
category, asmName, typeName, "Method", m.Name,
SafeMethodSignature(m)));
}
}
// Events
EventInfo[] events;
try { events = type.GetEvents(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static | BindingFlags.DeclaredOnly); }
catch { events = Array.Empty<EventInfo>(); }
foreach (var e in events)
{
if (typeMatches || keywords.Any(k => e.Name.Contains(k, StringComparison.OrdinalIgnoreCase)))
{
results.Add(new Candidate(
category, asmName, typeName, "Event", e.Name,
SafeEventSignature(e)));
}
}
}
}
// Dedupe + sort deterministically.
return results
.Distinct()
.OrderBy(c => c.Category, StringComparer.Ordinal)
.ThenBy(c => c.Assembly, StringComparer.Ordinal)
.ThenBy(c => c.TypeName, StringComparer.Ordinal)
.ThenBy(c => c.MemberKind, StringComparer.Ordinal)
.ThenBy(c => c.MemberName, StringComparer.Ordinal)
.ThenBy(c => c.Signature, StringComparer.Ordinal)
.ToList();
}
private static string SafePropertySignature(PropertyInfo p)
{
try { return $"{SafeTypeName(p.PropertyType)} {p.Name} {{ {(p.CanRead ? "get;" : "")}{(p.CanWrite ? " set;" : "")} }}"; }
catch { return p.Name; }
}
private static string SafeMethodSignature(MethodInfo m)
{
try
{
var sb = new StringBuilder();
sb.Append(SafeTypeName(m.ReturnType)).Append(' ').Append(m.Name).Append('(');
var ps = m.GetParameters();
for (int i = 0; i < ps.Length; i++)
{
if (i > 0) sb.Append(", ");
sb.Append(SafeTypeName(ps[i].ParameterType)).Append(' ').Append(ps[i].Name ?? $"arg{i}");
}
sb.Append(')');
return sb.ToString();
}
catch { return m.Name; }
}
private static string SafeEventSignature(EventInfo e)
{
try { return $"{SafeTypeName(e.EventHandlerType ?? typeof(object))} {e.Name}"; }
catch { return e.Name; }
}
private static string SafeTypeName(Type t)
{
try { return t.FullName ?? t.Name; }
catch { return "?"; }
}
}

View File

@@ -0,0 +1,59 @@
using System.Reflection;
using System.Text.Json;
namespace Recordingtest.Hmeg.Catalog;
public sealed record TypeEntry(string Assembly, string TypeName, bool IsPublic, string Namespace);
public static class CatalogWriter
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
WriteIndented = true,
};
public static IReadOnlyList<TypeEntry> BuildTypeEntries(IEnumerable<TypeInfo> types)
{
var list = new List<TypeEntry>();
foreach (var t in types)
{
var asm = t.Assembly.GetName().Name ?? "?";
var ns = t.Namespace ?? string.Empty;
var name = t.FullName ?? t.Name;
list.Add(new TypeEntry(asm, name, t.IsPublic, ns));
}
return list
.Distinct()
.OrderBy(e => e.Assembly, StringComparer.Ordinal)
.ThenBy(e => e.TypeName, StringComparer.Ordinal)
.ToList();
}
public static void WriteTypes(string path, IEnumerable<TypeInfo> types)
{
var entries = BuildTypeEntries(types);
WriteJson(path, entries);
}
public static void WriteCandidates(string path, IReadOnlyList<Candidate> candidates)
{
var sorted = candidates
.OrderBy(c => c.Category, StringComparer.Ordinal)
.ThenBy(c => c.Assembly, StringComparer.Ordinal)
.ThenBy(c => c.TypeName, StringComparer.Ordinal)
.ThenBy(c => c.MemberKind, StringComparer.Ordinal)
.ThenBy(c => c.MemberName, StringComparer.Ordinal)
.ThenBy(c => c.Signature, StringComparer.Ordinal)
.ToList();
WriteJson(path, sorted);
}
private static void WriteJson<T>(string path, T value)
{
var dir = Path.GetDirectoryName(Path.GetFullPath(path));
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
// Normalize forward-slash line endings + trailing newline for determinism.
var json = JsonSerializer.Serialize(value, JsonOptions).Replace("\r\n", "\n") + "\n";
File.WriteAllText(path, json);
}
}

View File

@@ -0,0 +1,30 @@
namespace Recordingtest.Hmeg.Catalog;
/// <summary>
/// Skeleton implementation of <see cref="IEngineSnapshot"/> for HmEG.
/// Runtime probe is deferred to v2; all members throw NotImplementedException.
/// The constants below mark the reflection anchor points that PoC v2 will
/// bind to. The generator-time static catalog (see <see cref="CandidateFinder"/>)
/// cross-checks that these names exist in the real SUT assemblies.
/// </summary>
public sealed class HmEgSnapshot : IEngineSnapshot
{
public const string HmEgAssemblyHint = "HmEG";
public const string EditorManagerTypeHint = "HmEGAppManager";
public const string SelectionTypeHint = "Selection";
public const string CameraTypeHint = "Camera";
public const string SceneTypeHint = "Scene";
public const string RenderTypeHint = "Render";
public IReadOnlyList<string> SelectedObjectIds
=> throw new NotImplementedException("Runtime probe deferred to v2");
public CameraState Camera
=> throw new NotImplementedException("Runtime probe deferred to v2");
public SceneSummary Scene
=> throw new NotImplementedException("Runtime probe deferred to v2");
public bool IsRenderComplete
=> throw new NotImplementedException("Runtime probe deferred to v2");
}

View File

@@ -0,0 +1,13 @@
namespace Recordingtest.Hmeg.Catalog;
public interface IEngineSnapshot
{
IReadOnlyList<string> SelectedObjectIds { get; }
CameraState Camera { get; }
SceneSummary Scene { get; }
bool IsRenderComplete { get; }
}
public sealed record CameraState(double[] EyePoint, double[] Target, double[] Up, double Fov);
public sealed record SceneSummary(int ObjectCount, string? DocumentPath);

View File

@@ -0,0 +1,73 @@
using System.Reflection;
using System.Runtime.InteropServices;
namespace Recordingtest.Hmeg.Catalog;
/// <summary>
/// Thin wrapper around <see cref="MetadataLoadContext"/>. This class is
/// metadata-only: it never invokes any static constructor or user code from
/// the loaded assemblies. See the MetadataLoadContext documentation:
/// https://learn.microsoft.com/dotnet/api/system.reflection.metadataloadcontext.
/// </summary>
public sealed class MetadataLoader : IDisposable
{
private readonly MetadataLoadContext _mlc;
private readonly string _sutRoot;
public MetadataLoader(string sutRoot)
{
if (string.IsNullOrWhiteSpace(sutRoot)) throw new ArgumentException("sutRoot required", nameof(sutRoot));
_sutRoot = Path.GetFullPath(sutRoot);
var runtimeDir = RuntimeEnvironment.GetRuntimeDirectory();
var paths = new List<string>();
if (Directory.Exists(_sutRoot))
{
paths.AddRange(Directory.EnumerateFiles(_sutRoot, "*.dll", SearchOption.TopDirectoryOnly));
}
if (Directory.Exists(runtimeDir))
{
paths.AddRange(Directory.EnumerateFiles(runtimeDir, "*.dll", SearchOption.TopDirectoryOnly));
}
var resolver = new PathAssemblyResolver(paths);
_mlc = new MetadataLoadContext(resolver);
}
public IEnumerable<TypeInfo> LoadTypes(IEnumerable<string> assemblyFileNames)
{
foreach (var name in assemblyFileNames.OrderBy(n => n, StringComparer.Ordinal))
{
var path = Path.Combine(_sutRoot, name);
if (!File.Exists(path)) continue;
Assembly asm;
try
{
asm = _mlc.LoadFromAssemblyPath(path);
}
catch (Exception)
{
// Unreadable assembly — skip.
continue;
}
TypeInfo[] types;
try
{
types = asm.DefinedTypes.ToArray();
}
catch (ReflectionTypeLoadException rtle)
{
types = rtle.Types.Where(t => t != null).Select(t => t!.GetTypeInfo()).ToArray();
}
foreach (var t in types)
{
yield return t;
}
}
}
public void Dispose() => _mlc.Dispose();
}

View File

@@ -0,0 +1,10 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<AssemblyName>Recordingtest.Hmeg.Catalog</AssemblyName>
<RootNamespace>Recordingtest.Hmeg.Catalog</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="System.Reflection.MetadataLoadContext" Version="8.0.0" />
<PackageReference Include="System.Text.Json" Version="8.0.5" />
</ItemGroup>
</Project>