fix(transport): base-first template reference resolution on import (ScadaBridge#100 item 1)

TemplateLibrary.GetIdByNameAsync was first-row-wins, so a derived slot template
could shadow a same-named base. Now: exact name, base first, derived-only name
only when unique, otherwise unresolved (composition audit says ambiguous).
Tracked-template indexes are base-first too.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
This commit is contained in:
Joseph Doherty
2026-11-10 04:38:05 -05:00
parent 545d3e8ba0
commit a13aabc7c3
3 changed files with 135 additions and 10 deletions
@@ -2402,6 +2402,29 @@ public sealed class BundleImporter : IBundleImporter
internal static Dictionary<string, Template> ToTemplateLookup(IEnumerable<Template> rows) =>
ToNameLookup(rows.OrderBy(t => t.IsDerived), t => t.Name);
/// <summary>The by-name reference rule of <c>TemplateLibrary.GetIdByNameAsync</c>
/// (ScadaBridge#100 item 1): exact name; base template first; a derived-only name resolves
/// only when unique; several derived and no base ⇒ omitted (ambiguous).</summary>
internal static Dictionary<string, int> ResolveTemplateReferenceIds(
IEnumerable<(int Id, string Name, bool IsDerived)> pairs)
{
var result = new Dictionary<string, int>(StringComparer.Ordinal);
foreach (var group in pairs.GroupBy(p => p.Name, StringComparer.Ordinal))
{
var bases = group.Where(p => !p.IsDerived).ToList();
if (bases.Count > 0)
{
result[group.Key] = bases.Min(p => p.Id);
continue;
}
var derived = group.ToList();
if (derived.Count == 1)
result[group.Key] = derived[0].Id;
}
return result;
}
private async Task ApplyTemplatesAsync(
IReadOnlyList<TemplateDto> dtos,
Dictionary<(string, string), ImportResolution> map,
@@ -3367,7 +3390,10 @@ public sealed class BundleImporter : IBundleImporter
OwnerTemplateName = parent.Name,
compDto.InstanceName,
UnresolvedComposedTemplateName = compDto.ComposedTemplateName,
Reason = "Composed template name not present in bundle or target.",
Reason = (await library.GetAmbiguousNamesAsync(ct).ConfigureAwait(false))
.Contains(compDto.ComposedTemplateName)
? "Composed template name is held by several derived (composition-slot) templates in the target and by no base template; refusing to guess (ScadaBridge#100)."
: "Composed template name not present in bundle or target.",
},
ct).ConfigureAwait(false);
continue;
@@ -3492,7 +3518,9 @@ public sealed class BundleImporter : IBundleImporter
public LocalTemplateIndex(IReadOnlyList<Template> tracked)
{
foreach (var template in tracked)
// Base first (ScadaBridge#100 item 1): a tracked derived slot template must not
// shadow a base template of the same name for a composed/base reference.
foreach (var template in tracked.OrderBy(t => t.IsDerived))
{
_exact.TryAdd(template.Name, template);
}
@@ -3633,19 +3661,43 @@ public sealed class BundleImporter : IBundleImporter
return _analysis;
}
/// <summary>Target template name → id (first wins on a duplicate name).</summary>
/// <summary>
/// Target template name → id, used to resolve a bundle's by-name references (composed,
/// base and instance template names) against templates this import did not stage.
/// <para>
/// ScadaBridge#100 item 1 — the rule, keyed by exact name: a <b>base</b> template wins
/// (base names are unique through the filtered unique index, and the base is the row a
/// by-name reference means — the same rule <see cref="ToTemplateLookup"/> applies to
/// Overwrite targets); a name held only by derived (composition-slot) templates resolves
/// only when exactly ONE derived template holds it; a name held by several derived
/// templates and no base is <b>ambiguous</b> and is left out, so the caller records it
/// unresolved rather than binding to an arbitrary slot owned by another composition.
/// It used to be first-row-wins over the projection order, so a derived slot template
/// with a lower id silently shadowed a base template of the same name.
/// </para>
/// </summary>
public async Task<IReadOnlyDictionary<string, int>> GetIdByNameAsync(CancellationToken ct)
{
if (_idByName is null)
{
var pairs = await GetPairsAsync(ct).ConfigureAwait(false);
_idByName = pairs
.GroupBy(p => p.Name, StringComparer.Ordinal)
.ToDictionary(g => g.Key, g => g.First().Id, StringComparer.Ordinal);
_idByName = ResolveTemplateReferenceIds(pairs);
}
return _idByName;
}
/// <summary>Target names held only by two or more derived templates (no base) —
/// the references <see cref="GetIdByNameAsync"/> refuses to guess.</summary>
public async Task<IReadOnlySet<string>> GetAmbiguousNamesAsync(CancellationToken ct)
{
var pairs = await GetPairsAsync(ct).ConfigureAwait(false);
return pairs
.GroupBy(p => p.Name, StringComparer.Ordinal)
.Where(g => g.All(p => p.IsDerived) && g.Count() > 1)
.Select(g => g.Key)
.ToHashSet(StringComparer.Ordinal);
}
/// <summary>Target template id → name.</summary>
public async Task<IReadOnlyDictionary<int, string>> GetNameByIdAsync(CancellationToken ct)
{
@@ -3659,14 +3711,14 @@ public sealed class BundleImporter : IBundleImporter
// Id/name pairs from whichever projection is already in hand: the analysis
// load when a caller has made it, else the lighter stub projection.
private async Task<IReadOnlyList<(int Id, string Name)>> GetPairsAsync(CancellationToken ct)
private async Task<IReadOnlyList<(int Id, string Name, bool IsDerived)>> GetPairsAsync(CancellationToken ct)
{
if (_analysis is not null)
{
return _analysis.Select(t => (t.Id, t.Name)).ToList();
return _analysis.Select(t => (t.Id, t.Name, t.IsDerived)).ToList();
}
_stubs ??= await _repo.GetTemplateStubsAsync(ct).ConfigureAwait(false);
return _stubs.Select(t => (t.Id, t.Name)).ToList();
return _stubs.Select(t => (t.Id, t.Name, t.IsDerived)).ToList();
}
}
@@ -4844,6 +4896,9 @@ public sealed class BundleImporter : IBundleImporter
await library.GetIdByNameAsync(ct).ConfigureAwait(false), StringComparer.Ordinal);
foreach (var local in _dbContext.Templates.Local)
{
// A tracked derived slot template never displaces a base of the same name
// (ScadaBridge#100 item 1 — instances are of base templates).
if (local.IsDerived && templateIdByName.ContainsKey(local.Name)) continue;
templateIdByName[local.Name] = local.Id;
}