fix(template-engine): resolve TemplateEngine-015,016 — cascade-rename nested derived templates, correct composed-script ParentPath

This commit is contained in:
Joseph Doherty
2026-05-17 03:18:41 -04:00
parent 0135a6b2a6
commit d6221419c6
5 changed files with 177 additions and 14 deletions

View File

@@ -714,7 +714,7 @@ public class FlatteningService
foreach (var composition in compositions)
ResolveComposedScriptsRecursive(
composition, composition.InstanceName,
composition, composition.InstanceName, parentPath: "",
compositionMap, composedTemplateChains, scripts, scriptCanonicalById,
new HashSet<int>());
}
@@ -723,11 +723,17 @@ public class FlatteningService
/// <summary>
/// Recursively resolves the scripts of a composed module and every module
/// nested inside it, path-qualifying each canonical name with the
/// accumulated <paramref name="prefix"/>.
/// accumulated <paramref name="prefix"/>. <paramref name="parentPath"/> is
/// the path of the enclosing module — empty for a depth-1 composition
/// (parent is the root template) and the enclosing module's
/// <c>prefix</c> for deeper nesting — and is carried into each script's
/// <see cref="ScriptScope"/> so a nested script's <c>Parent.X</c>
/// resolves against its real parent module.
/// </summary>
private static void ResolveComposedScriptsRecursive(
TemplateComposition composition,
string prefix,
string parentPath,
IReadOnlyDictionary<int, IReadOnlyList<TemplateComposition>> compositionMap,
IReadOnlyDictionary<int, IReadOnlyList<Template>> composedTemplateChains,
Dictionary<string, ResolvedScript> scripts,
@@ -747,7 +753,7 @@ public class FlatteningService
{
CanonicalName = canonicalName,
Source = "Composed",
Scope = new Commons.Types.Scripts.ScriptScope(SelfPath: prefix, ParentPath: "")
Scope = new Commons.Types.Scripts.ScriptScope(SelfPath: prefix, ParentPath: parentPath)
};
}
}
@@ -762,7 +768,7 @@ public class FlatteningService
foreach (var nested in nestedCompositions)
ResolveComposedScriptsRecursive(
nested, $"{prefix}.{nested.InstanceName}",
nested, $"{prefix}.{nested.InstanceName}", parentPath: prefix,
compositionMap, composedTemplateChains, scripts, scriptCanonicalById, visited);
}
}

View File

@@ -705,12 +705,28 @@ public class TemplateService
{
var newDerivedName = $"{owner.Name}.{newInstanceName}";
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
if (allTemplates.Any(t => t.Id != derived.Id && t.Name == newDerivedName))
return Result<TemplateComposition>.Failure(
$"Cannot rename derived template to '{newDerivedName}': a template with that name already exists.");
derived.Name = newDerivedName;
await _repository.UpdateTemplateAsync(derived, cancellationToken);
// The cascade of derived templates created by AddComposition follows a
// dotted path (Pump.TempSensor and the nested Pump.TempSensor.Probe1).
// Renaming the slot must rename every derived template in that cascade
// so the dotted-path naming invariant holds — pre-check every new name
// the cascade will introduce before any row mutates.
var renames = new List<(Template Template, string NewName)>();
await CollectCascadeRenamesAsync(derived, newDerivedName, renames, cancellationToken);
var renamedIds = renames.Select(r => r.Template.Id).ToHashSet();
foreach (var (_, newName) in renames)
{
if (allTemplates.Any(t => !renamedIds.Contains(t.Id) && t.Name == newName))
return Result<TemplateComposition>.Failure(
$"Cannot rename derived template to '{newName}': a template with that name already exists.");
}
foreach (var (template, newName) in renames)
{
template.Name = newName;
await _repository.UpdateTemplateAsync(template, cancellationToken);
}
}
composition.InstanceName = newInstanceName;
@@ -747,6 +763,30 @@ public class TemplateService
return Result<bool>.Success(true);
}
/// <summary>
/// Recursively collects the (template, new name) pairs for a renamed derived
/// template and every cascaded inner derived template beneath it. Each inner
/// derived's new name is re-derived from its renamed parent and the slot's
/// instance name (mirroring the cascade <see cref="CreateCascadedCompositionAsync"/>
/// builds and the recursion in <see cref="CascadeDeleteDerivedAsync"/>).
/// </summary>
private async Task CollectCascadeRenamesAsync(
Template derived,
string newName,
List<(Template Template, string NewName)> renames,
CancellationToken cancellationToken)
{
renames.Add((derived, newName));
foreach (var child in derived.Compositions.ToList())
{
var childDerived = await _repository.GetTemplateByIdAsync(child.ComposedTemplateId, cancellationToken);
if (childDerived != null && childDerived.IsDerived && childDerived.OwnerCompositionId == child.Id)
await CollectCascadeRenamesAsync(
childDerived, $"{newName}.{child.InstanceName}", renames, cancellationToken);
}
}
/// <summary>
/// Recursively deletes a derived template along with the cascade of inner
/// derived templates the compose flow created. Each composition row on the