Files
ScadaBridge/src/ScadaLink.TemplateEngine/Validation/SemanticValidator.cs
T
Joseph Doherty 751248feb6 feat(alarms): HiLo trigger type with per-band level, hysteresis, messages, overrides
Adds a new HiLo alarm trigger type with four configurable setpoints
(LoLo / Lo / Hi / HiHi). Each setpoint carries an optional priority,
deadband (for hysteresis), and operator message. The site runtime emits
AlarmStateChanged with an AlarmLevel field so consumers can differentiate
warning vs critical bands.

Plumbing:
  - new AlarmLevel enum + AlarmStateChanged.Level/Message init properties
  - AlarmTriggerEditor (Blazor) gets a HiLo render with severity tinting
  - AlarmTriggerConfigCodec extracted from the editor for testability
  - sitestream.proto carries level + message over gRPC
  - SemanticValidator enforces numeric attribute, setpoint ordering,
    non-negative deadband
  - on-trigger scripts get an Alarm global (Name/Level/Priority/Message)
    so notification routing can branch by severity
  - per-instance InstanceAlarmOverride entity + EF migration + flattening
    step + CLI commands; HiLo overrides merge setpoint-by-setpoint, binary
    types whole-replace
  - DebugView shows a Level badge + per-band message tooltip
  - App.razor auto-reloads on permanent Blazor circuit failure
  - docker/regen-proto.sh automates the proto regen workflow (the linux/arm64
    protoc segfault means generated files are checked in for now)
2026-05-13 03:23:32 -04:00

397 lines
16 KiB
C#

using System.Text.Json;
using ScadaLink.Commons.Types.Flattening;
namespace ScadaLink.TemplateEngine.Validation;
/// <summary>
/// Semantic validation rules for a FlattenedConfiguration:
/// - CallScript/CallShared targets must reference existing scripts
/// - Parameter count and types must match
/// - Return type compatibility
/// - Trigger operand types: RangeViolation requires numeric attribute
/// - On-trigger script must exist
/// - Instance scripts cannot call alarm on-trigger scripts
/// </summary>
public class SemanticValidator
{
// Known numeric data types for RangeViolation trigger type validation
private static readonly HashSet<string> NumericDataTypes = new(StringComparer.OrdinalIgnoreCase)
{
"Int32", "Float", "Double"
};
/// <summary>
/// Runs all semantic validation rules.
/// </summary>
/// <param name="configuration">The flattened configuration to validate.</param>
/// <param name="sharedScripts">Shared scripts available for CallShared references.</param>
public ValidationResult Validate(
FlattenedConfiguration configuration,
IReadOnlyList<ResolvedScript>? sharedScripts = null)
{
var errors = new List<ValidationEntry>();
var warnings = new List<ValidationEntry>();
var scriptNames = new HashSet<string>(
configuration.Scripts.Select(s => s.CanonicalName), StringComparer.Ordinal);
var sharedScriptNames = new HashSet<string>(
(sharedScripts ?? []).Select(s => s.CanonicalName), StringComparer.Ordinal);
var attributeMap = new Dictionary<string, ResolvedAttribute>(StringComparer.Ordinal);
foreach (var a in configuration.Attributes)
{
// Skip duplicates — naming collisions are reported separately
attributeMap.TryAdd(a.CanonicalName, a);
}
// Collect alarm on-trigger script names for cross-call violation checks
var alarmOnTriggerScripts = new HashSet<string>(StringComparer.Ordinal);
foreach (var alarm in configuration.Alarms)
{
if (!string.IsNullOrWhiteSpace(alarm.OnTriggerScriptCanonicalName))
alarmOnTriggerScripts.Add(alarm.OnTriggerScriptCanonicalName);
}
// Build parameter maps for call target validation
var scriptParamMap = BuildParameterMap(configuration.Scripts);
var sharedParamMap = BuildParameterMap(sharedScripts ?? []);
var scriptReturnMap = BuildReturnMap(configuration.Scripts);
var sharedReturnMap = BuildReturnMap(sharedScripts ?? []);
foreach (var script in configuration.Scripts)
{
var callTargets = ExtractCallTargets(script.Code);
foreach (var call in callTargets)
{
if (call.IsShared)
{
// CallShared targets must reference existing shared scripts
if (!sharedScriptNames.Contains(call.TargetName))
{
errors.Add(ValidationEntry.Error(ValidationCategory.CallTargetNotFound,
$"Script '{script.CanonicalName}' calls shared script '{call.TargetName}' which does not exist.",
script.CanonicalName));
}
else
{
ValidateCallParameters(script.CanonicalName, call, sharedParamMap, errors);
}
}
else
{
// CallScript targets must reference existing instance scripts
if (!scriptNames.Contains(call.TargetName))
{
errors.Add(ValidationEntry.Error(ValidationCategory.CallTargetNotFound,
$"Script '{script.CanonicalName}' calls script '{call.TargetName}' which does not exist.",
script.CanonicalName));
}
else
{
ValidateCallParameters(script.CanonicalName, call, scriptParamMap, errors);
// Instance scripts cannot call alarm on-trigger scripts
if (alarmOnTriggerScripts.Contains(call.TargetName))
{
errors.Add(ValidationEntry.Error(ValidationCategory.CrossCallViolation,
$"Script '{script.CanonicalName}' calls alarm on-trigger script '{call.TargetName}' which is not allowed.",
script.CanonicalName));
}
}
}
}
}
// Validate Call-type scripts have parameter definitions
foreach (var script in configuration.Scripts)
{
if (string.Equals(script.TriggerType, "Call", StringComparison.OrdinalIgnoreCase))
{
if (string.IsNullOrWhiteSpace(script.ParameterDefinitions))
{
warnings.Add(ValidationEntry.Warning(ValidationCategory.MissingMetadata,
$"Call-type script '{script.CanonicalName}' has no parameter definitions.",
script.CanonicalName));
}
}
}
// Validate alarm trigger operand types
foreach (var alarm in configuration.Alarms)
{
// RangeViolation requires numeric attribute
if (alarm.TriggerType == "RangeViolation" && !string.IsNullOrWhiteSpace(alarm.TriggerConfiguration))
{
var attrName = ValidationService.ExtractAttributeNameFromTriggerConfig(alarm.TriggerConfiguration);
if (attrName != null && attributeMap.TryGetValue(attrName, out var attr))
{
if (!NumericDataTypes.Contains(attr.DataType))
{
errors.Add(ValidationEntry.Error(ValidationCategory.TriggerOperandType,
$"Alarm '{alarm.CanonicalName}' uses RangeViolation trigger on attribute '{attrName}' which has non-numeric type '{attr.DataType}'.",
alarm.CanonicalName));
}
}
}
// HiLo requires numeric attribute + ordered setpoints
if (alarm.TriggerType == "HiLo" && !string.IsNullOrWhiteSpace(alarm.TriggerConfiguration))
{
var attrName = ValidationService.ExtractAttributeNameFromTriggerConfig(alarm.TriggerConfiguration);
if (attrName != null && attributeMap.TryGetValue(attrName, out var attr))
{
if (!NumericDataTypes.Contains(attr.DataType))
{
errors.Add(ValidationEntry.Error(ValidationCategory.TriggerOperandType,
$"Alarm '{alarm.CanonicalName}' uses HiLo trigger on attribute '{attrName}' which has non-numeric type '{attr.DataType}'.",
alarm.CanonicalName));
}
}
var setpoints = ValidationService.ExtractHiLoSetpoints(alarm.TriggerConfiguration);
// At least one setpoint must be configured — otherwise the alarm
// can never fire.
if (!setpoints.LoLo.HasValue && !setpoints.Lo.HasValue
&& !setpoints.Hi.HasValue && !setpoints.HiHi.HasValue)
{
warnings.Add(ValidationEntry.Warning(ValidationCategory.TriggerOperandType,
$"Alarm '{alarm.CanonicalName}' is HiLo but no setpoints (LoLo/Lo/Hi/HiHi) are configured — it will never fire.",
alarm.CanonicalName));
}
// Ordering: LoLo ≤ Lo, Hi ≤ HiHi, and the highest Lo-side band
// must sit strictly below the lowest Hi-side band — otherwise the
// bands overlap and the evaluator's behavior is ambiguous.
if (setpoints.LoLo is { } loLo && setpoints.Lo is { } lo && loLo > lo)
{
errors.Add(ValidationEntry.Error(ValidationCategory.TriggerOperandType,
$"Alarm '{alarm.CanonicalName}' HiLo setpoints out of order: LoLo ({loLo}) must be ≤ Lo ({lo}).",
alarm.CanonicalName));
}
if (setpoints.Hi is { } hi && setpoints.HiHi is { } hiHi && hi > hiHi)
{
errors.Add(ValidationEntry.Error(ValidationCategory.TriggerOperandType,
$"Alarm '{alarm.CanonicalName}' HiLo setpoints out of order: Hi ({hi}) must be ≤ HiHi ({hiHi}).",
alarm.CanonicalName));
}
var highestLowSide = setpoints.Lo ?? setpoints.LoLo;
var lowestHighSide = setpoints.Hi ?? setpoints.HiHi;
if (highestLowSide is { } lowSide && lowestHighSide is { } highSide
&& lowSide >= highSide)
{
errors.Add(ValidationEntry.Error(ValidationCategory.TriggerOperandType,
$"Alarm '{alarm.CanonicalName}' HiLo bands overlap: low-side setpoint ({lowSide}) must be strictly less than high-side setpoint ({highSide}).",
alarm.CanonicalName));
}
// Deadbands must be non-negative — negative deadband would invert
// the hysteresis (alarm could escape faster than it entered).
foreach (var (name, value) in new (string, double?)[] {
("LoLo deadband", setpoints.LoLoDeadband),
("Lo deadband", setpoints.LoDeadband),
("Hi deadband", setpoints.HiDeadband),
("HiHi deadband", setpoints.HiHiDeadband)
})
{
if (value is { } d && d < 0)
{
errors.Add(ValidationEntry.Error(ValidationCategory.TriggerOperandType,
$"Alarm '{alarm.CanonicalName}' {name} ({d}) must be non-negative.",
alarm.CanonicalName));
}
}
}
// On-trigger script must exist
if (!string.IsNullOrWhiteSpace(alarm.OnTriggerScriptCanonicalName) &&
!scriptNames.Contains(alarm.OnTriggerScriptCanonicalName))
{
errors.Add(ValidationEntry.Error(ValidationCategory.OnTriggerScriptNotFound,
$"Alarm '{alarm.CanonicalName}' references on-trigger script '{alarm.OnTriggerScriptCanonicalName}' which does not exist.",
alarm.CanonicalName));
}
}
return new ValidationResult { Errors = errors, Warnings = warnings };
}
private static void ValidateCallParameters(
string callerName,
CallTarget call,
Dictionary<string, List<string>> paramMap,
List<ValidationEntry> errors)
{
if (!paramMap.TryGetValue(call.TargetName, out var expectedParams))
return;
if (call.ArgumentCount != expectedParams.Count)
{
errors.Add(ValidationEntry.Error(ValidationCategory.ParameterMismatch,
$"Script '{callerName}' calls '{call.TargetName}' with {call.ArgumentCount} arguments but {expectedParams.Count} are expected.",
callerName));
}
}
private static Dictionary<string, List<string>> BuildParameterMap(IReadOnlyList<ResolvedScript> scripts)
{
var result = new Dictionary<string, List<string>>(StringComparer.Ordinal);
foreach (var script in scripts)
{
var parameters = ParseParameterDefinitions(script.ParameterDefinitions);
result[script.CanonicalName] = parameters;
}
return result;
}
private static Dictionary<string, string?> BuildReturnMap(IReadOnlyList<ResolvedScript> scripts)
{
var result = new Dictionary<string, string?>(StringComparer.Ordinal);
foreach (var script in scripts)
{
result[script.CanonicalName] = script.ReturnDefinition;
}
return result;
}
internal static List<string> ParseParameterDefinitions(string? parameterDefinitionsJson)
{
if (string.IsNullOrWhiteSpace(parameterDefinitionsJson))
return [];
try
{
using var doc = JsonDocument.Parse(parameterDefinitionsJson);
// JSON Schema: { type:"object", properties:{ name:{...}, ... }, required:[...] }
if (doc.RootElement.ValueKind == JsonValueKind.Object)
{
if (doc.RootElement.TryGetProperty("properties", out var props)
&& props.ValueKind == JsonValueKind.Object)
{
return props.EnumerateObject().Select(p => p.Name).ToList();
}
}
// Legacy flat form: [{ name, type, required? }]
else if (doc.RootElement.ValueKind == JsonValueKind.Array)
{
return doc.RootElement.EnumerateArray()
.Select(e => e.TryGetProperty("type", out var t) ? t.GetString() ?? "unknown" : "unknown")
.ToList();
}
}
catch (JsonException)
{
}
return [];
}
/// <summary>
/// Extracts call targets from script code by simple pattern matching.
/// Looks for CallScript("name", ...) and CallShared("name", ...) patterns.
/// </summary>
internal static List<CallTarget> ExtractCallTargets(string code)
{
var results = new List<CallTarget>();
ExtractCallsOfType(code, "CallScript", false, results);
ExtractCallsOfType(code, "CallShared", true, results);
return results;
}
private static void ExtractCallsOfType(string code, string methodName, bool isShared, List<CallTarget> results)
{
var searchPattern = methodName + "(";
int pos = 0;
while (pos < code.Length)
{
var idx = code.IndexOf(searchPattern, pos, StringComparison.Ordinal);
if (idx < 0) break;
var argsStart = idx + searchPattern.Length;
var target = ExtractStringArgument(code, argsStart);
if (target != null)
{
var argCount = CountArguments(code, argsStart);
results.Add(new CallTarget
{
TargetName = target,
IsShared = isShared,
ArgumentCount = Math.Max(0, argCount - 1) // First arg is the name, rest are parameters
});
}
pos = argsStart;
}
}
private static string? ExtractStringArgument(string code, int startPos)
{
// Skip whitespace
var pos = startPos;
while (pos < code.Length && char.IsWhiteSpace(code[pos])) pos++;
if (pos >= code.Length) return null;
// Expect a quote
var quote = code[pos];
if (quote != '"' && quote != '\'') return null;
pos++;
var nameStart = pos;
while (pos < code.Length && code[pos] != quote) pos++;
if (pos >= code.Length) return null;
return code[nameStart..pos];
}
private static int CountArguments(string code, int startPos)
{
var depth = 1;
var count = 1; // At least one argument (the name)
var pos = startPos;
while (pos < code.Length && depth > 0)
{
switch (code[pos])
{
case '(':
depth++;
break;
case ')':
depth--;
break;
case ',' when depth == 1:
count++;
break;
case '"':
case '\'':
// Skip string literals
var quote = code[pos];
pos++;
while (pos < code.Length && code[pos] != quote)
{
if (code[pos] == '\\') pos++; // Skip escaped chars
pos++;
}
break;
}
pos++;
}
return count;
}
internal record CallTarget
{
public string TargetName { get; init; } = string.Empty;
public bool IsShared { get; init; }
public int ArgumentCount { get; init; }
}
}