feat(raw): B3-WP3 rename-warning script scan + historized-override refine

Extend RawTreeService.BuildRenameWarningsAsync so a tag/ancestor rename now
raises three warning kinds over the affected (prefix-scanned) tags:

(a) refined — historized WITHOUT a historianTagname override (default tagname
    is the moving RawPath, so history forks); a pinned (override) tag no longer
    warns. Count/message adjusted.
(b) unchanged — UNS-referenced (names the equipment).
(c) NEW — substring-scan every Script body for the affected tag's OLD RawPath
    (ordinal, false-positives tolerated by design; no AST). OLD RawPaths are
    recomputed from the pre-save in-DB topology via RawPathResolver.

CollectTagsBeneath* + TagsForDevices now carry (DeviceId, TagGroupId, Name) so
the OLD RawPath can be rebuilt. Contained entirely within RawTreeService.cs;
IRawTreeService + RawTree.razor untouched (warnings flow through the existing
RawRenameResult.Warnings). Note: a direct tag rename goes through UpdateTagAsync
(UnsMutationResult, no warning surface) so it is out of scope without widening
the interface.

Tests: new RawTreeServiceRenameWarningTests (4) — all-three, pinned-no-warn,
unrelated-empty, ancestor-rename script match. AdminUI.Tests 642 green.

Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
This commit is contained in:
Joseph Doherty
2026-07-16 06:04:06 -04:00
parent a88dc86173
commit 09ff43910c
2 changed files with 357 additions and 27 deletions
@@ -1282,24 +1282,41 @@ public sealed class RawTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
}
/// <summary>
/// Builds the non-blocking rename warnings answerable from this batch's schema: historized tags and
/// equipment-referenced tags beneath the renamed node (whose RawPath — and thus historian tagname /
/// UNS projection path — changes with the rename). Batch 3 appends the script-substring scan here.
/// A raw tag affected by a rename, carrying the fields needed to (a) read its platform intents and
/// (b) recompute its OLD RawPath (still the in-DB state at warning time — the rename is not yet saved).
/// </summary>
private readonly record struct AffectedTag(
string TagId, string TagConfig, string DeviceId, string? TagGroupId, string Name);
/// <summary>
/// Builds the non-blocking rename warnings answerable from this batch's schema for the tags beneath the
/// renamed node (whose RawPath — and thus historian tagname / UNS projection path / script literal —
/// changes with the rename): (a) historized tags WITHOUT a <c>historianTagname</c> override (their
/// history forks, since the default tagname is the moving RawPath; pinned tags are unaffected),
/// (b) equipment-referenced tags, and (c) tags whose OLD RawPath appears as a literal substring in any
/// <see cref="Script"/> body (tolerates false positives by design — no AST).
/// </summary>
private static async Task<IReadOnlyList<string>> BuildRenameWarningsAsync(
OtOpcUaConfigDbContext db, IReadOnlyList<(string TagId, string TagConfig)> tags, CancellationToken ct)
OtOpcUaConfigDbContext db, IReadOnlyList<AffectedTag> tags, CancellationToken ct)
{
var warnings = new List<string>();
if (tags.Count == 0) return warnings;
var historized = tags.Count(t => TagConfigIntent.Parse(t.TagConfig).IsHistorized);
if (historized > 0)
// (a) Historized WITHOUT a historianTagname override: the default tagname is the RawPath, which is
// moving, so history forks on the next deploy. A tag WITH the override is pinned and does not warn.
var forking = tags.Count(t =>
{
warnings.Add(historized == 1
? "1 historized tag beneath this node will change its RawPath (historian tagname). Update the historian if it keys on the old path."
: $"{historized} historized tags beneath this node will change their RawPath (historian tagname). Update the historian if it keys on the old paths.");
var intent = TagConfigIntent.Parse(t.TagConfig);
return intent.IsHistorized && intent.HistorianTagname is null;
});
if (forking > 0)
{
warnings.Add(forking == 1
? "1 historized tag beneath this node has no historianTagname override; its history forks to a new tagname when its RawPath changes on the next deploy. Set a historianTagname to pin it."
: $"{forking} historized tags beneath this node have no historianTagname override; their history forks to new tagnames when their RawPaths change on the next deploy. Set a historianTagname to pin them.");
}
// (b) UNS-referenced: name the referencing equipment.
var tagIds = tags.Select(t => t.TagId).ToList();
var referencingEquipmentIds = await db.UnsTagReferences.AsNoTracking()
.Where(r => tagIds.Contains(r.TagId))
@@ -1313,12 +1330,94 @@ public sealed class RawTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
warnings.Add($"Tags beneath this node are referenced by equipment {display}; the UNS projection path will change with the rename.");
}
// Batch 3 extension point: scan scripts for substrings of the affected tags' RawPaths and append
// a "N script(s) reference tags beneath this node" warning to this same list.
// (c) Script literals: substring-scan every Script body for the affected tags' OLD RawPaths.
var scriptWarning = await BuildScriptReferenceWarningAsync(db, tags, ct);
if (scriptWarning is not null) warnings.Add(scriptWarning);
return warnings;
}
/// <summary>
/// Scans every <see cref="Script"/> body for the OLD RawPath of any affected tag as a plain ordinal
/// substring (no AST — false positives tolerated by design; <c>{{equip}}</c>-relative refs are NOT
/// scanned, they resolve through references and the deploy gate catches breakage). Returns a warning
/// naming the matched scripts, or <see langword="null"/> when none match.
/// </summary>
private static async Task<string?> BuildScriptReferenceWarningAsync(
OtOpcUaConfigDbContext db, IReadOnlyList<AffectedTag> tags, CancellationToken ct)
{
var oldPaths = await ComputeOldRawPathsAsync(db, tags, ct);
if (oldPaths.Count == 0) return null;
var scripts = await db.Scripts.AsNoTracking()
.Select(s => new { s.ScriptId, s.Name, s.SourceCode })
.ToListAsync(ct);
var matched = scripts
.Where(s => oldPaths.Any(p => s.SourceCode.Contains(p, StringComparison.Ordinal)))
.OrderBy(s => s.Name, StringComparer.Ordinal)
.Select(s => s.Name)
.ToList();
if (matched.Count == 0) return null;
var names = string.Join(", ", matched.Select(n => $"'{n}'"));
return matched.Count == 1
? $"1 script ({names}) references a tag beneath this node by its raw path; the reference will break unless the script is updated."
: $"{matched.Count} scripts ({names}) reference tags beneath this node by their raw paths; the references will break unless the scripts are updated.";
}
/// <summary>
/// Recomputes the OLD RawPath of each affected tag from the current (pre-save) in-DB raw topology,
/// dropping any tag whose ancestry chain is broken (a null RawPath). Loads only the topology the affected
/// tags need: their devices + drivers, the drivers' clusters' folders, and the devices' tag groups.
/// </summary>
private static async Task<IReadOnlyList<string>> ComputeOldRawPathsAsync(
OtOpcUaConfigDbContext db, IReadOnlyList<AffectedTag> tags, CancellationToken ct)
{
var deviceIds = tags.Select(t => t.DeviceId).Distinct(StringComparer.Ordinal).ToList();
var deviceRows = await db.Devices.AsNoTracking()
.Where(d => deviceIds.Contains(d.DeviceId))
.Select(d => new { d.DeviceId, d.DriverInstanceId, d.Name })
.ToListAsync(ct);
var driverIds = deviceRows.Select(d => d.DriverInstanceId).Distinct(StringComparer.Ordinal).ToList();
var driverRows = await db.DriverInstances.AsNoTracking()
.Where(d => driverIds.Contains(d.DriverInstanceId))
.Select(d => new { d.DriverInstanceId, d.RawFolderId, d.Name, d.ClusterId })
.ToListAsync(ct);
var clusterIds = driverRows.Select(d => d.ClusterId).Distinct(StringComparer.Ordinal).ToList();
var folderRows = await db.RawFolders.AsNoTracking()
.Where(f => clusterIds.Contains(f.ClusterId))
.Select(f => new { f.RawFolderId, f.ParentRawFolderId, f.Name })
.ToListAsync(ct);
var groupRows = await db.TagGroups.AsNoTracking()
.Where(g => deviceIds.Contains(g.DeviceId))
.Select(g => new { g.TagGroupId, g.ParentTagGroupId, g.Name })
.ToListAsync(ct);
var folders = folderRows.ToDictionary(
f => f.RawFolderId, f => ((string?)f.ParentRawFolderId, f.Name), StringComparer.Ordinal);
var drivers = driverRows.ToDictionary(
d => d.DriverInstanceId, d => ((string?)d.RawFolderId, d.Name), StringComparer.Ordinal);
var devices = deviceRows.ToDictionary(
d => d.DeviceId, d => (d.DriverInstanceId, d.Name), StringComparer.Ordinal);
var groups = groupRows.ToDictionary(
g => g.TagGroupId, g => ((string?)g.ParentTagGroupId, g.Name), StringComparer.Ordinal);
var resolver = new RawPathResolver(folders, drivers, devices, groups);
var paths = new List<string>(tags.Count);
foreach (var t in tags)
{
var path = resolver.TryBuildTagPath(t.DeviceId, t.TagGroupId, t.Name);
if (path is not null) paths.Add(path);
}
return paths;
}
/// <summary>Renders a friendly "<c>Name (Id)</c>, …" list for the referencing equipment ids.</summary>
private static async Task<string> DescribeEquipmentAsync(
OtOpcUaConfigDbContext db, IReadOnlyList<string> equipmentIds, CancellationToken ct)
@@ -1336,11 +1435,11 @@ public sealed class RawTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
// --- Subtree tag collectors (walk the in-DB subtree for rename impact) ---
private static async Task<IReadOnlyList<(string TagId, string TagConfig)>> CollectTagsBeneathFolderAsync(
private static async Task<IReadOnlyList<AffectedTag>> CollectTagsBeneathFolderAsync(
OtOpcUaConfigDbContext db, string rawFolderId, CancellationToken ct)
{
var folder = await db.RawFolders.AsNoTracking().FirstOrDefaultAsync(f => f.RawFolderId == rawFolderId, ct);
if (folder is null) return Array.Empty<(string, string)>();
if (folder is null) return Array.Empty<AffectedTag>();
// All folders in the cluster, walked in-memory to the descendant set (incl. self) via ParentRawFolderId.
var clusterFolders = await db.RawFolders.AsNoTracking()
@@ -1359,44 +1458,44 @@ public sealed class RawTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
.Where(d => d.RawFolderId != null && folderIds.Contains(d.RawFolderId))
.Select(d => d.DriverInstanceId)
.ToListAsync(ct);
if (driverIds.Count == 0) return Array.Empty<(string, string)>();
if (driverIds.Count == 0) return Array.Empty<AffectedTag>();
var deviceIds = await db.Devices.AsNoTracking()
.Where(dev => driverIds.Contains(dev.DriverInstanceId))
.Select(dev => dev.DeviceId)
.ToListAsync(ct);
if (deviceIds.Count == 0) return Array.Empty<(string, string)>();
if (deviceIds.Count == 0) return Array.Empty<AffectedTag>();
return await TagsForDevicesAsync(db, deviceIds, ct);
}
private static async Task<IReadOnlyList<(string TagId, string TagConfig)>> CollectTagsBeneathDriverAsync(
private static async Task<IReadOnlyList<AffectedTag>> CollectTagsBeneathDriverAsync(
OtOpcUaConfigDbContext db, string driverInstanceId, CancellationToken ct)
{
var deviceIds = await db.Devices.AsNoTracking()
.Where(dev => dev.DriverInstanceId == driverInstanceId)
.Select(dev => dev.DeviceId)
.ToListAsync(ct);
if (deviceIds.Count == 0) return Array.Empty<(string, string)>();
if (deviceIds.Count == 0) return Array.Empty<AffectedTag>();
return await TagsForDevicesAsync(db, deviceIds, ct);
}
private static async Task<IReadOnlyList<(string TagId, string TagConfig)>> CollectTagsBeneathDeviceAsync(
private static async Task<IReadOnlyList<AffectedTag>> CollectTagsBeneathDeviceAsync(
OtOpcUaConfigDbContext db, string deviceId, CancellationToken ct)
{
return (await db.Tags.AsNoTracking()
.Where(t => t.DeviceId == deviceId)
.Select(t => new { t.TagId, t.TagConfig })
.Select(t => new { t.TagId, t.TagConfig, t.DeviceId, t.TagGroupId, t.Name })
.ToListAsync(ct))
.Select(t => (t.TagId, t.TagConfig))
.Select(t => new AffectedTag(t.TagId, t.TagConfig, t.DeviceId, t.TagGroupId, t.Name))
.ToList();
}
private static async Task<IReadOnlyList<(string TagId, string TagConfig)>> CollectTagsBeneathGroupAsync(
private static async Task<IReadOnlyList<AffectedTag>> CollectTagsBeneathGroupAsync(
OtOpcUaConfigDbContext db, string tagGroupId, CancellationToken ct)
{
var group = await db.TagGroups.AsNoTracking().FirstOrDefaultAsync(g => g.TagGroupId == tagGroupId, ct);
if (group is null) return Array.Empty<(string, string)>();
if (group is null) return Array.Empty<AffectedTag>();
var deviceGroups = await db.TagGroups.AsNoTracking()
.Where(g => g.DeviceId == group.DeviceId)
@@ -1412,20 +1511,20 @@ public sealed class RawTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
return (await db.Tags.AsNoTracking()
.Where(t => t.TagGroupId != null && groupIds.Contains(t.TagGroupId))
.Select(t => new { t.TagId, t.TagConfig })
.Select(t => new { t.TagId, t.TagConfig, t.DeviceId, t.TagGroupId, t.Name })
.ToListAsync(ct))
.Select(t => (t.TagId, t.TagConfig))
.Select(t => new AffectedTag(t.TagId, t.TagConfig, t.DeviceId, t.TagGroupId, t.Name))
.ToList();
}
private static async Task<IReadOnlyList<(string TagId, string TagConfig)>> TagsForDevicesAsync(
private static async Task<IReadOnlyList<AffectedTag>> TagsForDevicesAsync(
OtOpcUaConfigDbContext db, IReadOnlyList<string> deviceIds, CancellationToken ct)
{
return (await db.Tags.AsNoTracking()
.Where(t => deviceIds.Contains(t.DeviceId))
.Select(t => new { t.TagId, t.TagConfig })
.Select(t => new { t.TagId, t.TagConfig, t.DeviceId, t.TagGroupId, t.Name })
.ToListAsync(ct))
.Select(t => (t.TagId, t.TagConfig))
.Select(t => new AffectedTag(t.TagId, t.TagConfig, t.DeviceId, t.TagGroupId, t.Name))
.ToList();
}