fix(transport): warn on import of instance overrides targeting locked template members — inert rows no longer silent (plan R2-05 T7)
This commit is contained in:
@@ -684,8 +684,9 @@ public sealed class BundleImporter : IBundleImporter
|
||||
// its new name, but at preview time no resolution has been chosen yet, so
|
||||
// the in-bundle name is the original DTO name — which is what an instance
|
||||
// references. (Explicit rename remap is a D-wave apply-time concern.)
|
||||
var targetTemplates = await _templateRepo.GetAllTemplatesAsync(ct).ConfigureAwait(false);
|
||||
var targetTemplateNames = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var t in await _templateRepo.GetAllTemplatesAsync(ct).ConfigureAwait(false))
|
||||
foreach (var t in targetTemplates)
|
||||
{
|
||||
targetTemplateNames.Add(t.Name);
|
||||
}
|
||||
@@ -883,6 +884,22 @@ public sealed class BundleImporter : IBundleImporter
|
||||
}
|
||||
}
|
||||
|
||||
// N4: advisory warning rows for instance overrides that target a LOCKED
|
||||
// template member — the flattener drops them, so surface the inert row in the
|
||||
// wizard (Kind: Warning, non-blocking). resolutionMap is null at preview time,
|
||||
// so every instance is scanned.
|
||||
foreach (var (instance, message) in CollectLockedOverrideWarnings(content, resolutionMap: null, targetTemplates))
|
||||
{
|
||||
blockers.Add(new ImportPreviewItem(
|
||||
EntityType: "Instance",
|
||||
Name: instance,
|
||||
ExistingVersion: null,
|
||||
IncomingVersion: null,
|
||||
Kind: ConflictKind.Warning,
|
||||
FieldDiffJson: null,
|
||||
BlockerReason: message));
|
||||
}
|
||||
|
||||
return blockers;
|
||||
}
|
||||
|
||||
@@ -1075,6 +1092,85 @@ public sealed class BundleImporter : IBundleImporter
|
||||
=> resolutionMap != null
|
||||
&& ResolveOrDefault(resolutionMap, entityType, name).Action == ResolutionAction.Skip;
|
||||
|
||||
/// <summary>
|
||||
/// Best-effort scan for instance overrides that target a LOCKED template member.
|
||||
/// The flattener drops such overrides (an override on a locked attribute / alarm /
|
||||
/// native-alarm-source never takes effect), so the bundle carries an inert row —
|
||||
/// this surfaces an advisory warning so the operator learns the config won't apply,
|
||||
/// WITHOUT blocking the import or changing what gets written (bundle fidelity keeps
|
||||
/// the row; the flattener stays the behavioural authority).
|
||||
/// <para>
|
||||
/// Matches locked members by DIRECT name only — own + inherited placeholder rows
|
||||
/// both carry the <c>IsLocked</c> flag, so a lock on either matches. Overrides
|
||||
/// addressing composed path-qualified members (<c>y1.z.Val</c>) or derived-shadow
|
||||
/// locks (<c>LockedInDerived</c> on a base the placeholder row doesn't reflect) may
|
||||
/// not match a direct row — an unmatched name emits NO warning (never a false
|
||||
/// positive; the ManagementActor and flattener remain the enforcement points). The
|
||||
/// instance's template is resolved from the bundle DTO (the version about to be
|
||||
/// written) first, else the pre-existing target template.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private static IReadOnlyList<(string Instance, string Message)> CollectLockedOverrideWarnings(
|
||||
BundleContentDto content,
|
||||
Dictionary<(string, string), ImportResolution>? resolutionMap,
|
||||
IReadOnlyList<Template> targetTemplates)
|
||||
{
|
||||
var warnings = new List<(string, string)>();
|
||||
|
||||
var bundleTemplates = new Dictionary<string, TemplateDto>(StringComparer.Ordinal);
|
||||
foreach (var t in content.Templates) bundleTemplates[t.Name] = t;
|
||||
var targetByName = new Dictionary<string, Template>(StringComparer.Ordinal);
|
||||
foreach (var t in targetTemplates) targetByName[t.Name] = t;
|
||||
|
||||
foreach (var inst in content.Instances)
|
||||
{
|
||||
if (IsSkipResolution(resolutionMap, "Instance", inst.UniqueName)) continue;
|
||||
if (string.IsNullOrEmpty(inst.TemplateName)) continue;
|
||||
|
||||
HashSet<string> lockedAttrs;
|
||||
HashSet<string> lockedAlarms;
|
||||
HashSet<string> lockedSources;
|
||||
if (bundleTemplates.TryGetValue(inst.TemplateName, out var dto))
|
||||
{
|
||||
lockedAttrs = new(dto.Attributes.Where(a => a.IsLocked).Select(a => a.Name), StringComparer.Ordinal);
|
||||
lockedAlarms = new(dto.Alarms.Where(a => a.IsLocked).Select(a => a.Name), StringComparer.Ordinal);
|
||||
lockedSources = new(dto.NativeAlarmSources.Where(s => s.IsLocked).Select(s => s.Name), StringComparer.Ordinal);
|
||||
}
|
||||
else if (targetByName.TryGetValue(inst.TemplateName, out var tgt))
|
||||
{
|
||||
lockedAttrs = new(tgt.Attributes.Where(a => a.IsLocked).Select(a => a.Name), StringComparer.Ordinal);
|
||||
lockedAlarms = new(tgt.Alarms.Where(a => a.IsLocked).Select(a => a.Name), StringComparer.Ordinal);
|
||||
lockedSources = new(tgt.NativeAlarmSources.Where(s => s.IsLocked).Select(s => s.Name), StringComparer.Ordinal);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Template unresolvable — a separate blocker/validation covers that.
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var o in inst.AttributeOverrides)
|
||||
{
|
||||
if (lockedAttrs.Contains(o.AttributeName))
|
||||
warnings.Add((inst.UniqueName, LockedOverrideMessage(inst.UniqueName, "attribute", o.AttributeName)));
|
||||
}
|
||||
foreach (var o in inst.AlarmOverrides)
|
||||
{
|
||||
if (lockedAlarms.Contains(o.AlarmCanonicalName))
|
||||
warnings.Add((inst.UniqueName, LockedOverrideMessage(inst.UniqueName, "alarm", o.AlarmCanonicalName)));
|
||||
}
|
||||
foreach (var o in inst.NativeAlarmSourceOverrides)
|
||||
{
|
||||
if (lockedSources.Contains(o.SourceCanonicalName))
|
||||
warnings.Add((inst.UniqueName, LockedOverrideMessage(inst.UniqueName, "native-alarm-source", o.SourceCanonicalName)));
|
||||
}
|
||||
}
|
||||
return warnings;
|
||||
}
|
||||
|
||||
private static string LockedOverrideMessage(string unique, string kind, string name) =>
|
||||
$"Instance '{unique}' {kind} override '{name}' targets a LOCKED template member — " +
|
||||
"the flattener ignores it, so this part of the bundle's instance config will never take effect.";
|
||||
|
||||
/// <summary>
|
||||
/// Names that look like PascalCase references but are never user-defined
|
||||
/// SharedScripts or ExternalSystems. Filters the false-positive noise the
|
||||
@@ -1242,7 +1338,7 @@ public sealed class BundleImporter : IBundleImporter
|
||||
if (validationWarnings.Count > 0)
|
||||
{
|
||||
_logger?.LogWarning(
|
||||
"Bundle import {BundleImportId}: {Count} advisory template-script reference warning(s): {Warnings}",
|
||||
"Bundle import {BundleImportId}: {Count} advisory import warning(s): {Warnings}",
|
||||
bundleImportId, validationWarnings.Count, string.Join("; ", validationWarnings));
|
||||
}
|
||||
|
||||
@@ -4375,6 +4471,15 @@ public sealed class BundleImporter : IBundleImporter
|
||||
// (Pass 0 emits only errors), so nothing surfacable is dropped.
|
||||
if (errors.Count > 0) return (errors, warnings);
|
||||
|
||||
// N4: advisory warnings for instance overrides that target a LOCKED template
|
||||
// member. Best-effort, non-blocking (rides ImportResult.Warnings); the row is
|
||||
// still written (bundle fidelity) — the flattener is the behavioural authority.
|
||||
var lockScanTemplates = await _templateRepo.GetAllTemplatesAsync(ct).ConfigureAwait(false);
|
||||
foreach (var (_, message) in CollectLockedOverrideWarnings(content, resolutionMap, lockScanTemplates))
|
||||
{
|
||||
warnings.Add(message);
|
||||
}
|
||||
|
||||
// ---- Pass 1: minimal name-resolution scan ----
|
||||
|
||||
// Build the known-resolvable set. For in-bundle entries, EXCLUDE the
|
||||
|
||||
Reference in New Issue
Block a user