Implement engine-bridge PoC v1 (#9)
- Add Recordingtest.EngineBridge library (IEngineSnapshot, HmEgSnapshot skeleton, MetadataLoader, CandidateFinder, CatalogWriter). - Add Recordingtest.EngineBridge.Probe console exe that dumps hmeg-types.json and hmeg-candidates.json to docs/engine-catalog. - Add Recordingtest.EngineBridge.Tests (xUnit, 6 tests). - Add probe design doc with plugin-masquerade recommendation. - Static analysis only; SUT is never executed. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
87
src/Recordingtest.EngineBridge.Probe/Program.cs
Normal file
87
src/Recordingtest.EngineBridge.Probe/Program.cs
Normal file
@@ -0,0 +1,87 @@
|
||||
using System.Reflection;
|
||||
using Recordingtest.EngineBridge;
|
||||
|
||||
namespace Recordingtest.EngineBridge.Probe;
|
||||
|
||||
internal static class Program
|
||||
{
|
||||
private static readonly string[] DefaultAssemblyPatterns =
|
||||
{
|
||||
"HmEG.dll",
|
||||
"HmGeometry.dll",
|
||||
"HmGeometry.V2.dll",
|
||||
"HmTriangle.dll",
|
||||
"EditorCore.dll",
|
||||
"Editor*.dll",
|
||||
};
|
||||
|
||||
internal static int Main(string[] args)
|
||||
{
|
||||
string sutRoot = "EG-BIM Modeler";
|
||||
string outDir = Path.Combine("docs", "engine-catalog");
|
||||
|
||||
for (int i = 0; i < args.Length; i++)
|
||||
{
|
||||
switch (args[i])
|
||||
{
|
||||
case "--sut" when i + 1 < args.Length:
|
||||
sutRoot = args[++i];
|
||||
break;
|
||||
case "--out" when i + 1 < args.Length:
|
||||
outDir = args[++i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!Directory.Exists(sutRoot))
|
||||
{
|
||||
Console.Error.WriteLine($"SUT path not found: {sutRoot}");
|
||||
return 2;
|
||||
}
|
||||
|
||||
var assemblyNames = ResolveAssemblyNames(sutRoot);
|
||||
Console.WriteLine($"Loading {assemblyNames.Count} assemblies from {sutRoot}");
|
||||
|
||||
using var loader = new MetadataLoader(sutRoot);
|
||||
var types = loader.LoadTypes(assemblyNames).ToList();
|
||||
|
||||
var byAsm = types
|
||||
.GroupBy(t => t.Assembly.GetName().Name ?? "?")
|
||||
.OrderBy(g => g.Key, StringComparer.Ordinal);
|
||||
foreach (var g in byAsm)
|
||||
{
|
||||
Console.WriteLine($" {g.Key}: {g.Count()} types");
|
||||
}
|
||||
|
||||
var candidates = CandidateFinder.Find(types);
|
||||
var byCat = candidates
|
||||
.GroupBy(c => c.Category)
|
||||
.OrderBy(g => g.Key, StringComparer.Ordinal);
|
||||
Console.WriteLine("Candidate categories:");
|
||||
foreach (var g in byCat)
|
||||
{
|
||||
Console.WriteLine($" {g.Key}: {g.Count()}");
|
||||
}
|
||||
|
||||
var typesPath = Path.Combine(outDir, "hmeg-types.json").Replace('\\', '/');
|
||||
var candPath = Path.Combine(outDir, "hmeg-candidates.json").Replace('\\', '/');
|
||||
CatalogWriter.WriteTypes(typesPath, types);
|
||||
CatalogWriter.WriteCandidates(candPath, candidates);
|
||||
Console.WriteLine($"Wrote {typesPath}");
|
||||
Console.WriteLine($"Wrote {candPath}");
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static List<string> ResolveAssemblyNames(string sutRoot)
|
||||
{
|
||||
var set = new SortedSet<string>(StringComparer.Ordinal);
|
||||
foreach (var pat in DefaultAssemblyPatterns)
|
||||
{
|
||||
foreach (var f in Directory.EnumerateFiles(sutRoot, pat, SearchOption.TopDirectoryOnly))
|
||||
{
|
||||
set.Add(Path.GetFileName(f));
|
||||
}
|
||||
}
|
||||
return set.ToList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<AssemblyName>Recordingtest.EngineBridge.Probe</AssemblyName>
|
||||
<RootNamespace>Recordingtest.EngineBridge.Probe</RootNamespace>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Recordingtest.EngineBridge\Recordingtest.EngineBridge.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
130
src/Recordingtest.EngineBridge/CandidateFinder.cs
Normal file
130
src/Recordingtest.EngineBridge/CandidateFinder.cs
Normal file
@@ -0,0 +1,130 @@
|
||||
using System.Reflection;
|
||||
using System.Text;
|
||||
|
||||
namespace Recordingtest.EngineBridge;
|
||||
|
||||
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 "?"; }
|
||||
}
|
||||
}
|
||||
59
src/Recordingtest.EngineBridge/CatalogWriter.cs
Normal file
59
src/Recordingtest.EngineBridge/CatalogWriter.cs
Normal file
@@ -0,0 +1,59 @@
|
||||
using System.Reflection;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Recordingtest.EngineBridge;
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
30
src/Recordingtest.EngineBridge/HmEgSnapshot.cs
Normal file
30
src/Recordingtest.EngineBridge/HmEgSnapshot.cs
Normal file
@@ -0,0 +1,30 @@
|
||||
namespace Recordingtest.EngineBridge;
|
||||
|
||||
/// <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");
|
||||
}
|
||||
13
src/Recordingtest.EngineBridge/IEngineSnapshot.cs
Normal file
13
src/Recordingtest.EngineBridge/IEngineSnapshot.cs
Normal file
@@ -0,0 +1,13 @@
|
||||
namespace Recordingtest.EngineBridge;
|
||||
|
||||
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);
|
||||
73
src/Recordingtest.EngineBridge/MetadataLoader.cs
Normal file
73
src/Recordingtest.EngineBridge/MetadataLoader.cs
Normal file
@@ -0,0 +1,73 @@
|
||||
using System.Reflection;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace Recordingtest.EngineBridge;
|
||||
|
||||
/// <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();
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<AssemblyName>Recordingtest.EngineBridge</AssemblyName>
|
||||
<RootNamespace>Recordingtest.EngineBridge</RootNamespace>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="System.Reflection.MetadataLoadContext" Version="8.0.0" />
|
||||
<PackageReference Include="System.Text.Json" Version="8.0.5" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
Reference in New Issue
Block a user