fix(template-engine): repeated composition no longer drops nested members — cycle guard keyed on recursion path, not global template id

Claude-Session: https://claude.ai/code/session_01MtdgwpEeCUn6cUA5f1LMPj
This commit is contained in:
Joseph Doherty
2026-07-09 15:38:20 -04:00
parent d19ab93d39
commit 730bf19162
2 changed files with 171 additions and 32 deletions
@@ -262,7 +262,7 @@ public class FlatteningService
IReadOnlyDictionary<int, IReadOnlyList<TemplateComposition>> compositionMap,
IReadOnlyDictionary<int, IReadOnlyList<Template>> composedTemplateChains,
Dictionary<string, ResolvedAttribute> attributes,
HashSet<int> visited)
HashSet<int> path)
{
if (!composedTemplateChains.TryGetValue(composition.ComposedTemplateId, out var composedChain))
return;
@@ -283,17 +283,27 @@ public class FlatteningService
}
// Descend into nested compositions of every template in the chain.
// Guard on the recursion PATH, not globally: composition is by-slot, so a
// template legitimately appears twice under different prefixes; only a
// template already on the CURRENT path is a cycle.
foreach (var composedTemplate in composedChain)
{
if (!visited.Add(composedTemplate.Id))
continue;
if (!compositionMap.TryGetValue(composedTemplate.Id, out var nestedCompositions))
if (!path.Add(composedTemplate.Id))
continue;
try
{
if (!compositionMap.TryGetValue(composedTemplate.Id, out var nestedCompositions))
continue;
foreach (var nested in nestedCompositions)
ResolveComposedAttributesRecursive(
nested, $"{prefix}.{nested.InstanceName}",
compositionMap, composedTemplateChains, attributes, visited);
foreach (var nested in nestedCompositions)
ResolveComposedAttributesRecursive(
nested, $"{prefix}.{nested.InstanceName}",
compositionMap, composedTemplateChains, attributes, path);
}
finally
{
path.Remove(composedTemplate.Id);
}
}
}
@@ -625,7 +635,7 @@ public class FlatteningService
IReadOnlyDictionary<int, IReadOnlyList<Template>> composedTemplateChains,
Dictionary<string, ResolvedAlarm> alarms,
Dictionary<string, int> alarmScriptIds,
HashSet<int> visited)
HashSet<int> path)
{
if (!composedTemplateChains.TryGetValue(composition.ComposedTemplateId, out var composedChain))
return;
@@ -646,17 +656,27 @@ public class FlatteningService
}
// Descend into nested compositions of every template in the chain.
// Guard on the recursion PATH, not globally: composition is by-slot, so a
// template legitimately appears twice under different prefixes; only a
// template already on the CURRENT path is a cycle.
foreach (var composedTemplate in composedChain)
{
if (!visited.Add(composedTemplate.Id))
continue;
if (!compositionMap.TryGetValue(composedTemplate.Id, out var nestedCompositions))
if (!path.Add(composedTemplate.Id))
continue;
try
{
if (!compositionMap.TryGetValue(composedTemplate.Id, out var nestedCompositions))
continue;
foreach (var nested in nestedCompositions)
ResolveComposedAlarmsRecursive(
nested, $"{prefix}.{nested.InstanceName}",
compositionMap, composedTemplateChains, alarms, alarmScriptIds, visited);
foreach (var nested in nestedCompositions)
ResolveComposedAlarmsRecursive(
nested, $"{prefix}.{nested.InstanceName}",
compositionMap, composedTemplateChains, alarms, alarmScriptIds, path);
}
finally
{
path.Remove(composedTemplate.Id);
}
}
}
@@ -739,7 +759,7 @@ public class FlatteningService
IReadOnlyDictionary<int, IReadOnlyList<TemplateComposition>> compositionMap,
IReadOnlyDictionary<int, IReadOnlyList<Template>> composedTemplateChains,
Dictionary<string, ResolvedNativeAlarmSource> sources,
HashSet<int> visited)
HashSet<int> path)
{
if (!composedTemplateChains.TryGetValue(composition.ComposedTemplateId, out var composedChain))
return;
@@ -758,17 +778,27 @@ public class FlatteningService
}
}
// Guard on the recursion PATH, not globally: composition is by-slot, so a
// template legitimately appears twice under different prefixes; only a
// template already on the CURRENT path is a cycle.
foreach (var composedTemplate in composedChain)
{
if (!visited.Add(composedTemplate.Id))
continue;
if (!compositionMap.TryGetValue(composedTemplate.Id, out var nestedCompositions))
if (!path.Add(composedTemplate.Id))
continue;
try
{
if (!compositionMap.TryGetValue(composedTemplate.Id, out var nestedCompositions))
continue;
foreach (var nested in nestedCompositions)
ResolveComposedNativeAlarmSourcesRecursive(
nested, $"{prefix}.{nested.InstanceName}",
compositionMap, composedTemplateChains, sources, visited);
foreach (var nested in nestedCompositions)
ResolveComposedNativeAlarmSourcesRecursive(
nested, $"{prefix}.{nested.InstanceName}",
compositionMap, composedTemplateChains, sources, path);
}
finally
{
path.Remove(composedTemplate.Id);
}
}
}
@@ -889,7 +919,7 @@ public class FlatteningService
IReadOnlyDictionary<int, IReadOnlyList<Template>> composedTemplateChains,
Dictionary<string, ResolvedScript> scripts,
Dictionary<int, string> scriptCanonicalById,
HashSet<int> visited)
HashSet<int> path)
{
if (!composedTemplateChains.TryGetValue(composition.ComposedTemplateId, out var composedChain))
return;
@@ -910,17 +940,27 @@ public class FlatteningService
}
// Descend into nested compositions of every template in the chain.
// Guard on the recursion PATH, not globally: composition is by-slot, so a
// template legitimately appears twice under different prefixes; only a
// template already on the CURRENT path is a cycle.
foreach (var composedTemplate in composedChain)
{
if (!visited.Add(composedTemplate.Id))
continue;
if (!compositionMap.TryGetValue(composedTemplate.Id, out var nestedCompositions))
if (!path.Add(composedTemplate.Id))
continue;
try
{
if (!compositionMap.TryGetValue(composedTemplate.Id, out var nestedCompositions))
continue;
foreach (var nested in nestedCompositions)
ResolveComposedScriptsRecursive(
nested, $"{prefix}.{nested.InstanceName}", parentPath: prefix,
compositionMap, composedTemplateChains, scripts, scriptCanonicalById, visited);
foreach (var nested in nestedCompositions)
ResolveComposedScriptsRecursive(
nested, $"{prefix}.{nested.InstanceName}", parentPath: prefix,
compositionMap, composedTemplateChains, scripts, scriptCanonicalById, path);
}
finally
{
path.Remove(composedTemplate.Id);
}
}
}