diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Repositories/ITemplateEngineRepository.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Repositories/ITemplateEngineRepository.cs
index 5cdc8c15..68146dd1 100644
--- a/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Repositories/ITemplateEngineRepository.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Repositories/ITemplateEngineRepository.cs
@@ -56,6 +56,19 @@ public interface ITemplateEngineRepository
/// A task that resolves to a read-only list of all templates without script bodies.
Task> GetAllTemplatesForAnalysisAsync(CancellationToken cancellationToken = default);
///
+ /// Returns the same untracked, script-body-free projection as
+ /// , but only for the templates an analysis walk
+ /// rooted at can reach: its inheritance chain plus the
+ /// composition closure of every chain member (see
+ /// ).
+ /// Backs member add/update validation (Gitea #66), which previously loaded every template.
+ /// Unknown ids yield an empty list.
+ ///
+ /// The template whose chain and composition closure to load.
+ /// Cancellation token.
+ /// A task that resolves to the closure templates without script bodies.
+ Task> GetTemplateAnalysisClosureAsync(int templateId, CancellationToken cancellationToken = default);
+ ///
/// Returns an untracked identity/edge projection of every template (no children), ordered by name.
///
/// Cancellation token.
diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Templates/TemplateAnalysisClosure.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Templates/TemplateAnalysisClosure.cs
new file mode 100644
index 00000000..11f97e76
--- /dev/null
+++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Templates/TemplateAnalysisClosure.cs
@@ -0,0 +1,113 @@
+using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
+
+namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Templates;
+
+///
+/// Computes the set of templates a member add/update analysis walk can reach from one template
+/// (Gitea #66): the template's inheritance chain (itself, its parent, grandparent, …) plus
+/// the composition closure of every chain member (each composed module, the modules they
+/// compose, and so on).
+///
+///
+/// This mirrors exactly what the analysis walkers read. TemplateResolver.ResolveAllMembers
+/// and CollisionDetector.DetectCollisions climb the parent chain of the template being
+/// analysed and descend composition edges from every chain member, but never climb a composed
+/// module's own parent chain (a derived module carries its inherited members as placeholder rows).
+/// So a composed module's ancestors are not loaded — only chain members expand their parent edge.
+/// Every other template in the library is irrelevant to the result, which is why member edits no
+/// longer need the whole analysis graph.
+///
+///
+///
+/// The walk is breadth-first by batch: each round asks loadBatch for every id not yet
+/// loaded, so a store-backed loader issues one query per closure depth rather than one per
+/// template. Missing ids (a dangling edge) are simply absent from the result, exactly as the
+/// walkers skip them. Cycles terminate: an id is expanded at most twice (once as a module, once
+/// more if it is later reached as a chain member).
+///
+///
+public static class TemplateAnalysisClosure
+{
+ ///
+ /// Collects the inheritance chain and composition closure of .
+ ///
+ /// The template whose analysis closure is wanted.
+ /// Loads the templates with the given ids (unknown ids are omitted).
+ /// Cancellation token.
+ /// The closure templates, each once, in load order (the root first when it exists).
+ public static async Task> CollectAsync(
+ int templateId,
+ Func, CancellationToken, Task>> loadBatch,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(loadBatch);
+
+ var loaded = new Dictionary();
+ var result = new List();
+ var requested = new HashSet();
+ // id -> true when expanded as a chain member (parent + compositions),
+ // false when expanded as a module only (compositions).
+ var expanded = new Dictionary();
+ // Ids reached but not loaded yet, with "reached as chain member" OR'd in.
+ var wanted = new Dictionary { [templateId] = true };
+
+ while (wanted.Count > 0)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ var toLoad = wanted.Keys.Where(requested.Add).ToArray();
+ if (toLoad.Length > 0)
+ {
+ var batch = await loadBatch(toLoad, cancellationToken).ConfigureAwait(false);
+ foreach (var t in batch)
+ {
+ if (loaded.TryAdd(t.Id, t))
+ result.Add(t);
+ }
+ }
+
+ var round = wanted.ToArray();
+ wanted.Clear();
+ foreach (var (id, asChain) in round)
+ Expand(id, asChain, loaded, expanded, wanted, requested);
+ }
+
+ return result;
+ }
+
+ private static void Expand(
+ int rootId,
+ bool rootAsChain,
+ Dictionary loaded,
+ Dictionary expanded,
+ Dictionary wanted,
+ HashSet requested)
+ {
+ var stack = new Stack<(int Id, bool AsChain)>();
+ stack.Push((rootId, rootAsChain));
+
+ while (stack.Count > 0)
+ {
+ var (id, asChain) = stack.Pop();
+
+ if (!loaded.TryGetValue(id, out var template))
+ {
+ // Already asked the loader and it does not exist: a dangling edge, skip it.
+ if (requested.Contains(id))
+ continue;
+ wanted[id] = asChain || (wanted.TryGetValue(id, out var w) && w);
+ continue;
+ }
+
+ if (expanded.TryGetValue(id, out var wasChain) && (wasChain || !asChain))
+ continue;
+ expanded[id] = asChain;
+
+ if (asChain && template.ParentTemplateId is int parentId)
+ stack.Push((parentId, true));
+
+ foreach (var composition in template.Compositions)
+ stack.Push((composition.ComposedTemplateId, false));
+ }
+ }
+}
diff --git a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/TemplateEngineRepository.cs b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/TemplateEngineRepository.cs
index 9d378d33..f1884cc5 100644
--- a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/TemplateEngineRepository.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/TemplateEngineRepository.cs
@@ -122,8 +122,33 @@ public class TemplateEngineRepository : ITemplateEngineRepository
// / .
public async Task> GetAllTemplatesForAnalysisAsync(CancellationToken cancellationToken = default)
{
- return await _context.Templates
- .AsNoTracking()
+ return await AnalysisProjection(_context.Templates.AsNoTracking())
+ .ToListAsync(cancellationToken);
+ }
+
+ ///
+ // One projection query per closure depth (TemplateAnalysisClosure batches each frontier),
+ // each restricted to the frontier's ids — so a member edit reads its own chain and modules
+ // instead of every template, attribute, alarm and script in the library.
+ public Task> GetTemplateAnalysisClosureAsync(int templateId, CancellationToken cancellationToken = default)
+ {
+ return TemplateAnalysisClosure.CollectAsync(
+ templateId,
+ async (ids, ct) =>
+ {
+ var idList = ids.ToList();
+ return await AnalysisProjection(_context.Templates.AsNoTracking().Where(t => idList.Contains(t.Id)))
+ .ToListAsync(ct);
+ },
+ cancellationToken);
+ }
+
+ // The analysis projection shared by GetAllTemplatesForAnalysisAsync and
+ // GetTemplateAnalysisClosureAsync (see the note on the former for why it is untracked and
+ // drops TemplateScript.Code).
+ private static IQueryable AnalysisProjection(IQueryable source)
+ {
+ return source
.Select(t => new Template(t.Name)
{
Id = t.Id,
@@ -152,8 +177,7 @@ public class TemplateEngineRepository : ITemplateEngineRepository
LockedInDerived = s.LockedInDerived
})
.ToList()
- })
- .ToListAsync(cancellationToken);
+ });
}
///
diff --git a/src/ZB.MOM.WW.ScadaBridge.TemplateEngine/TemplateService.cs b/src/ZB.MOM.WW.ScadaBridge.TemplateEngine/TemplateService.cs
index 6b74965d..58c514e1 100644
--- a/src/ZB.MOM.WW.ScadaBridge.TemplateEngine/TemplateService.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.TemplateEngine/TemplateService.cs
@@ -311,7 +311,8 @@ public class TemplateService
attribute.TemplateId = templateId;
// If inheriting, validate not trying to add a member that would collide
- var allTemplates = await _repository.GetAllTemplatesForAnalysisAsync(cancellationToken);
+ // Only the template's inheritance chain + composition closure can collide (#66).
+ var allTemplates = await _repository.GetTemplateAnalysisClosureAsync(templateId, cancellationToken);
var testTemplate = CloneTemplateWithNewAttribute(template, attribute);
var collisions = CollisionDetector.DetectCollisions(testTemplate, allTemplates);
if (collisions.Count > 0)
@@ -363,7 +364,8 @@ public class TemplateService
var template = await _repository.GetTemplateByIdAsync(existing.TemplateId, cancellationToken);
if (template?.ParentTemplateId != null)
{
- var allTemplates = await _repository.GetAllTemplatesForAnalysisAsync(cancellationToken);
+ // The parent's members resolve from its chain + composition closure alone (#66).
+ var allTemplates = await _repository.GetTemplateAnalysisClosureAsync(template.ParentTemplateId.Value, cancellationToken);
var parentMember = TemplateResolver.FindMemberByCanonicalName(
existing.Name, template.ParentTemplateId.Value, allTemplates);
if (parentMember != null && parentMember.IsLocked)
@@ -548,7 +550,8 @@ public class TemplateService
alarm.TemplateId = templateId;
// Check collisions
- var allTemplates = await _repository.GetAllTemplatesForAnalysisAsync(cancellationToken);
+ // Only the template's inheritance chain + composition closure can collide (#66).
+ var allTemplates = await _repository.GetTemplateAnalysisClosureAsync(templateId, cancellationToken);
var testTemplate = CloneTemplateWithNewAlarm(template, alarm);
var collisions = CollisionDetector.DetectCollisions(testTemplate, allTemplates);
if (collisions.Count > 0)
@@ -598,7 +601,8 @@ public class TemplateService
var template = await _repository.GetTemplateByIdAsync(existing.TemplateId, cancellationToken);
if (template?.ParentTemplateId != null)
{
- var allTemplates = await _repository.GetAllTemplatesForAnalysisAsync(cancellationToken);
+ // The parent's members resolve from its chain + composition closure alone (#66).
+ var allTemplates = await _repository.GetTemplateAnalysisClosureAsync(template.ParentTemplateId.Value, cancellationToken);
var parentMember = TemplateResolver.FindMemberByCanonicalName(
existing.Name, template.ParentTemplateId.Value, allTemplates);
if (parentMember != null && parentMember.IsLocked)
@@ -723,7 +727,8 @@ public class TemplateService
script.TemplateId = templateId;
// Check collisions
- var allTemplates = await _repository.GetAllTemplatesForAnalysisAsync(cancellationToken);
+ // Only the template's inheritance chain + composition closure can collide (#66).
+ var allTemplates = await _repository.GetTemplateAnalysisClosureAsync(templateId, cancellationToken);
var testTemplate = CloneTemplateWithNewScript(template, script);
var collisions = CollisionDetector.DetectCollisions(testTemplate, allTemplates);
if (collisions.Count > 0)
@@ -770,7 +775,8 @@ public class TemplateService
var template = await _repository.GetTemplateByIdAsync(existing.TemplateId, cancellationToken);
if (template?.ParentTemplateId != null)
{
- var allTemplates = await _repository.GetAllTemplatesForAnalysisAsync(cancellationToken);
+ // The parent's members resolve from its chain + composition closure alone (#66).
+ var allTemplates = await _repository.GetTemplateAnalysisClosureAsync(template.ParentTemplateId.Value, cancellationToken);
var parentMember = TemplateResolver.FindMemberByCanonicalName(
existing.Name, template.ParentTemplateId.Value, allTemplates);
if (parentMember != null && parentMember.IsLocked)
diff --git a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/TemplateAnalysisClosureRepositoryTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/TemplateAnalysisClosureRepositoryTests.cs
new file mode 100644
index 00000000..73e1d182
--- /dev/null
+++ b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/TemplateAnalysisClosureRepositoryTests.cs
@@ -0,0 +1,126 @@
+using Microsoft.EntityFrameworkCore;
+using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
+using ZB.MOM.WW.ScadaBridge.Commons.Types.Templates;
+using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
+
+namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests;
+
+///
+/// Gitea #66: reads only a
+/// template's inheritance chain + composition closure — one projection query per closure depth —
+/// instead of the whole library that
+/// materialises. Asserted against real SQL on SQLite with the reader commands recorded.
+///
+public class TemplateAnalysisClosureRepositoryTests : IDisposable
+{
+ private const int UnrelatedTemplates = 40;
+ private const string BigBody = "/* script body that must never be read by analysis */";
+
+ private readonly SqlReaderCommandRecorder _recorder = new();
+ private readonly ScadaBridgeDbContext _context;
+ private readonly TemplateEngineRepository _repository;
+
+ /// Initializes the test class with an in-memory context and repository.
+ public TemplateAnalysisClosureRepositoryTests()
+ {
+ _context = SqliteTestHelper.CreateInMemoryContext(_recorder);
+ _repository = new TemplateEngineRepository(_context, new TemplateGraphWatermark());
+ }
+
+ /// Releases the resources held by this test class.
+ public void Dispose()
+ {
+ _context.Database.CloseConnection();
+ _context.Dispose();
+ }
+
+ private async Task AddAsync(string name, int? parentId = null)
+ {
+ var t = new Template(name) { ParentTemplateId = parentId };
+ t.Attributes.Add(new TemplateAttribute($"{name}Attr1"));
+ t.Attributes.Add(new TemplateAttribute($"{name}Attr2"));
+ t.Scripts.Add(new TemplateScript($"{name}Script", BigBody));
+ _context.Templates.Add(t);
+ await _context.SaveChangesAsync();
+ return t;
+ }
+
+ private async Task ComposeAsync(Template owner, Template module, string slot)
+ {
+ _context.Set().Add(
+ new TemplateComposition(slot) { TemplateId = owner.Id, ComposedTemplateId = module.Id });
+ await _context.SaveChangesAsync();
+ }
+
+ private sealed record Seeded(int Grand, int Parent, int Child, int ModA, int ModB, int ModBBase, int Inner);
+
+ private async Task SeedAsync()
+ {
+ var grand = await AddAsync("Grand");
+ var parent = await AddAsync("Parent", grand.Id);
+ var child = await AddAsync("Child", parent.Id);
+ var modBBase = await AddAsync("ModBBase");
+ var modB = await AddAsync("ModB", modBBase.Id);
+ var modA = await AddAsync("ModA");
+ var inner = await AddAsync("Inner");
+ await ComposeAsync(parent, modA, "m1");
+ await ComposeAsync(child, modB, "m2");
+ await ComposeAsync(modA, inner, "inner");
+
+ for (var i = 0; i < UnrelatedTemplates; i++)
+ await AddAsync($"Unrelated{i}");
+
+ _context.ChangeTracker.Clear();
+ _recorder.Reset();
+ return new Seeded(grand.Id, parent.Id, child.Id, modA.Id, modB.Id, modBBase.Id, inner.Id);
+ }
+
+ ///
+ /// The closure is exactly the chain + composition closure, loaded untracked without script
+ /// bodies, in one query per depth — against 47 templates for the whole-graph load.
+ ///
+ /// A task that represents the asynchronous operation.
+ [Fact]
+ public async Task Closure_ReadsOnlyChainAndCompositionClosure_OneQueryPerDepth()
+ {
+ var ids = await SeedAsync();
+
+ var whole = await _repository.GetAllTemplatesForAnalysisAsync();
+ var wholeQueries = _recorder.Commands.Count;
+ _recorder.Reset();
+
+ var closure = await _repository.GetTemplateAnalysisClosureAsync(ids.Child);
+ var closureQueries = _recorder.Commands.Count;
+
+ // Entity count: 6 templates / 12 attributes instead of 47 / 94.
+ Assert.Equal(
+ new[] { ids.Grand, ids.Parent, ids.Child, ids.ModA, ids.ModB, ids.Inner }.OrderBy(i => i),
+ closure.Select(t => t.Id).OrderBy(i => i));
+ Assert.Equal(7 + UnrelatedTemplates, whole.Count);
+ Assert.Equal(12, closure.Sum(t => t.Attributes.Count));
+ Assert.Equal(2 * (7 + UnrelatedTemplates), whole.Sum(t => t.Attributes.Count));
+
+ // Query count: one projection query per depth (Child; Parent+ModB; Grand+ModA; Inner).
+ Assert.Equal(4 * wholeQueries, closureQueries);
+
+ // Same analysis shape as the whole-graph load: untracked, no script bodies, edges present.
+ Assert.Empty(_context.ChangeTracker.Entries());
+ Assert.All(closure.SelectMany(t => t.Scripts), s => Assert.Equal(string.Empty, s.Code));
+ Assert.False(_recorder.AnySelectReads("\"Code\""));
+ Assert.Single(closure.Single(t => t.Id == ids.Child).Compositions);
+
+ // Positive control: the body column IS visible to the recorder when a load reads it.
+ _recorder.Reset();
+ await _repository.GetAllTemplatesAsync();
+ Assert.True(_recorder.AnySelectReads("\"Code\""));
+ }
+
+ /// An unknown id loads nothing beyond the single probe query.
+ /// A task that represents the asynchronous operation.
+ [Fact]
+ public async Task Closure_UnknownTemplate_IsEmpty()
+ {
+ await SeedAsync();
+ Assert.Empty(await _repository.GetTemplateAnalysisClosureAsync(987654));
+ }
+}
diff --git a/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/AnalysisClosureMock.cs b/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/AnalysisClosureMock.cs
new file mode 100644
index 00000000..61db7f66
--- /dev/null
+++ b/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/AnalysisClosureMock.cs
@@ -0,0 +1,40 @@
+using Moq;
+using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
+using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
+using ZB.MOM.WW.ScadaBridge.Commons.Types.Templates;
+
+namespace ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests;
+
+///
+/// Gitea #66: member add/update now asks the repository for a template's analysis closure
+/// rather than the whole analysis graph. Test classes keep seeding the whole graph through
+/// GetAllTemplatesForAnalysisAsync; this wires GetTemplateAnalysisClosureAsync
+/// to serve the real closure walk over that seeded graph, so every existing scenario runs the
+/// service against the reduced set — the same set the production repository returns.
+///
+internal static class AnalysisClosureMock
+{
+ /// Wires the closure method to filter whatever the full-graph setup returns.
+ /// The repository mock.
+ /// Optional sink recording each requested closure root.
+ public static void ServeClosureFromFullGraph(
+ this Mock repoMock,
+ List? closureRequests = null)
+ {
+ repoMock
+ .Setup(r => r.GetTemplateAnalysisClosureAsync(It.IsAny(), It.IsAny()))
+ .Returns((int templateId, CancellationToken ct) =>
+ {
+ closureRequests?.Add(templateId);
+ return TemplateAnalysisClosure.CollectAsync(
+ templateId,
+ async (ids, token) =>
+ {
+ var all = await repoMock.Object.GetAllTemplatesForAnalysisAsync(token)
+ ?? Array.Empty();
+ return all.Where(t => ids.Contains(t.Id)).ToList();
+ },
+ ct);
+ });
+ }
+}
diff --git a/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/TemplateAnalysisClosureTests.cs b/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/TemplateAnalysisClosureTests.cs
new file mode 100644
index 00000000..529d77d6
--- /dev/null
+++ b/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/TemplateAnalysisClosureTests.cs
@@ -0,0 +1,162 @@
+using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
+using ZB.MOM.WW.ScadaBridge.Commons.Types.Templates;
+
+namespace ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests;
+
+///
+/// Gitea #66: member add/update validation now runs the analysis walkers over a template's
+/// inheritance chain + composition closure instead of the whole library. These tests pin that
+/// the closure is (a) sufficient — every walker answer is identical to the whole-graph answer,
+/// for every template in a graph with chains, nested and diamond compositions, a derived
+/// module, cycles, dangling edges and unrelated look-alikes — and (b) actually smaller.
+///
+public class TemplateAnalysisClosureTests
+{
+ // Ids of the fixture graph.
+ private const int Grand = 1, Parent = 2, Child = 3, ModA = 4, ModB = 5, ModBBase = 6, Inner = 7;
+ private const int CycX = 8, CycY = 9, Dangling = 10;
+ private const int Twin = 18, TwinParent = 19, TwinGrand = 20;
+
+ private static readonly List Graph = BuildGraph();
+
+ private static List BuildGraph()
+ {
+ var memberId = 1000;
+ Template T(int id, string name, int? parent = null, params string[] attrs)
+ {
+ var t = new Template(name) { Id = id, ParentTemplateId = parent };
+ foreach (var a in attrs)
+ t.Attributes.Add(new TemplateAttribute(a) { Id = memberId++, TemplateId = id, IsLocked = a.StartsWith('L') });
+ return t;
+ }
+
+ void Compose(Template owner, Template module, string slot) =>
+ owner.Compositions.Add(new TemplateComposition(slot) { Id = memberId++, TemplateId = owner.Id, ComposedTemplateId = module.Id });
+
+ var grand = T(Grand, "Grand", null, "LSpeed", "Shared");
+ var parent = T(Parent, "Parent", Grand, "Flow");
+ var child = T(Child, "Child", Parent, "Shared", "m1.Level"); // overrides + a would-be collision
+ var modA = T(ModA, "ModA", null, "Level", "Temp");
+ var modB = T(ModB, "ModB", ModBBase, "Base1"); // derived module (placeholder row)
+ var modBBase = T(ModBBase, "ModBBase", null, "Base1", "OnlyInBase");
+ var inner = T(Inner, "Inner", null, "Deep");
+ modB.Alarms.Add(new TemplateAlarm("HiHi") { Id = memberId++, TemplateId = ModB });
+ modA.Scripts.Add(new TemplateScript("Calc", string.Empty) { Id = memberId++, TemplateId = ModA, IsLocked = true });
+
+ Compose(parent, modA, "m1");
+ Compose(child, modB, "m2");
+ Compose(child, modA, "m1b"); // diamond: ModA reached by two paths
+ Compose(modA, inner, "inner");
+
+ var cycX = T(CycX, "CycX", null, "X");
+ var cycY = T(CycY, "CycY", null, "Y");
+ Compose(cycX, cycY, "y");
+ Compose(cycY, cycX, "x"); // composition cycle
+
+ var dangling = T(Dangling, "Dangling", 998, "D");
+ dangling.Compositions.Add(new TemplateComposition("ghost") { Id = memberId++, TemplateId = Dangling, ComposedTemplateId = 999 });
+
+ // A template reached first as a module and then as a chain member: its parent must still load.
+ var twinGrand = T(TwinGrand, "TwinGrand", null, "G");
+ var twinParent = T(TwinParent, "TwinParent", TwinGrand, "P");
+ var twin = T(Twin, "Twin", TwinParent, "S");
+ Compose(twin, twinParent, "t");
+
+ var all = new List { grand, parent, child, modA, modB, modBBase, inner, cycX, cycY, dangling, twinGrand, twinParent, twin };
+
+ // Unrelated library content with colliding member names — must never be loaded.
+ for (var i = 0; i < 5; i++)
+ all.Add(T(11 + i, $"Unrelated{i}", i == 0 ? null : 11, "Shared", "Level", "m1.Level"));
+
+ return all;
+ }
+
+ private static Task> InMemoryClosure(int id, List>? batches = null) =>
+ TemplateAnalysisClosure.CollectAsync(id, (ids, _) =>
+ {
+ batches?.Add(ids);
+ return Task.FromResult>(Graph.Where(t => ids.Contains(t.Id)).ToList());
+ });
+
+ public static IEnumerable