using System.Text.Json; using ScadaLink.Commons.Types.Flattening; namespace ScadaLink.TemplateEngine.Validation; /// /// 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 /// public class SemanticValidator { // Known numeric data types for RangeViolation trigger type validation private static readonly HashSet NumericDataTypes = new(StringComparer.OrdinalIgnoreCase) { "Int32", "Float", "Double" }; /// /// Runs all semantic validation rules. /// /// The flattened configuration to validate. /// Shared scripts available for CallShared references. public ValidationResult Validate( FlattenedConfiguration configuration, IReadOnlyList? sharedScripts = null) { var errors = new List(); var warnings = new List(); var scriptNames = new HashSet( configuration.Scripts.Select(s => s.CanonicalName), StringComparer.Ordinal); var sharedScriptNames = new HashSet( (sharedScripts ?? []).Select(s => s.CanonicalName), StringComparer.Ordinal); var attributeMap = new Dictionary(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(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> paramMap, List 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> BuildParameterMap(IReadOnlyList scripts) { var result = new Dictionary>(StringComparer.Ordinal); foreach (var script in scripts) { var parameters = ParseParameterDefinitions(script.ParameterDefinitions); result[script.CanonicalName] = parameters; } return result; } private static Dictionary BuildReturnMap(IReadOnlyList scripts) { var result = new Dictionary(StringComparer.Ordinal); foreach (var script in scripts) { result[script.CanonicalName] = script.ReturnDefinition; } return result; } internal static List 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 []; } /// /// Extracts call targets from script code by simple pattern matching. /// Looks for CallScript("name", ...) and CallShared("name", ...) patterns. /// internal static List ExtractCallTargets(string code) { var results = new List(); ExtractCallsOfType(code, "CallScript", false, results); ExtractCallsOfType(code, "CallShared", true, results); return results; } private static void ExtractCallsOfType(string code, string methodName, bool isShared, List 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; } } }