using System.Text.Json; using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening; using ZB.MOM.WW.ScadaBridge.ScriptAnalysis; namespace ZB.MOM.WW.ScadaBridge.TemplateEngine.Validation; /// /// Pre-deployment validation pipeline. Validates a FlattenedConfiguration for correctness /// before deployment. Also available on-demand (same logic, no deployment trigger). /// /// Validation checks: /// 1. Flattening success (no empty configuration) /// 2. No naming collisions /// 3. Script compilation (via ScriptCompiler) /// 4. Alarm trigger references exist (referenced attributes must be in the flattened config) /// 5. Script trigger references exist (referenced attributes must be in the flattened config) /// 6. Expression triggers — blank check, syntax check, and attribute-reference scan /// 7. Connection binding completeness — every data-sourced attribute must have a binding, /// and (on the deploy path) the bound connection must exist on the target site. /// Severity is context-dependent: a non-blocking Warning at template design time /// (bindings are set later) and a deploy-gating Error when enforced (M2.8 / #23). /// 8. List-attribute type semantics (MV-5, via ): /// element-type cardinality, default-value parseability, and trigger-operand /// rejection for List attributes. /// 9. Does NOT verify tag path resolution on devices /// public class ValidationService { private readonly SemanticValidator _semanticValidator; private readonly ScriptCompiler _scriptCompiler; /// /// Initializes a new instance of the ValidationService with the specified dependencies. /// /// The semantic validator for configuration validation. /// The script compiler for validating script code. public ValidationService(SemanticValidator semanticValidator, ScriptCompiler scriptCompiler) { _semanticValidator = semanticValidator; _scriptCompiler = scriptCompiler; } /// /// Convenience constructor that creates default dependencies. /// public ValidationService() : this(new SemanticValidator(), new ScriptCompiler()) { } /// /// Runs the full validation pipeline on a flattened configuration. /// /// The flattened configuration to validate. /// Optional list of shared scripts for validation context. /// /// Optional set of site data-connection names whose protocol resolves to an /// alarm-capable adapter (see /// ). When supplied, /// the semantic validator gates every native-alarm-source binding against it. /// null skips the capability check (its absence makes the check inert). /// /// /// M2.8 (#23): controls the severity of the connection-binding-completeness check. /// /// false (default) — template DESIGN-TIME: a data-sourced attribute that is /// not yet bound produces only a non-blocking Warning. Bindings are set later, /// at instance/deploy time, so an unbound data-sourced template attribute is legitimate /// here (see 's ValidateTemplate path, which builds a /// config straight from raw template members with no bindings). /// /// /// true — DEPLOY path ('s FlatteningPipeline): /// an unbound data-sourced attribute becomes a deploy-gating Error (IsValid false), /// and — when is supplied — a binding pointing at a /// connection that does not exist on the target site is also an Error. /// /// /// /// M2.8 (#23): optional set of the data-connection names that actually exist on the /// target site (computed by the deploy pipeline from the site's loaded connections, /// mirroring ). When supplied (and /// is true), every bound /// connection is checked against this set so a binding to a phantom/stale connection /// is caught. null skips the "exists at site" half (it stays inert). /// /// A merged aggregating all pipeline stage outcomes. public ValidationResult Validate( FlattenedConfiguration configuration, IReadOnlyList? sharedScripts = null, IReadOnlySet? alarmCapableConnectionNames = null, bool enforceConnectionBindings = false, IReadOnlySet? siteConnectionNames = null) { ArgumentNullException.ThrowIfNull(configuration); var results = new List { ValidateFlatteningSuccess(configuration), ValidateNamingCollisions(configuration), ValidateScriptCompilation(configuration), ValidateAlarmTriggerReferences(configuration), ValidateScriptTriggerReferences(configuration), ValidateExpressionTriggers(configuration), ValidateConnectionBindingCompleteness(configuration, enforceConnectionBindings, siteConnectionNames), _semanticValidator.Validate(configuration, sharedScripts, alarmCapableConnectionNames) }; return ValidationResult.Merge(results.ToArray()); } /// /// Validates that flattening produced a non-empty configuration. /// /// The flattened configuration to validate. /// A with errors or warnings if the configuration is empty or missing a name; otherwise success. public static ValidationResult ValidateFlatteningSuccess(FlattenedConfiguration configuration) { var errors = new List(); if (string.IsNullOrWhiteSpace(configuration.InstanceUniqueName)) errors.Add(ValidationEntry.Error(ValidationCategory.FlatteningFailure, "Instance unique name is missing.")); if (configuration.Attributes.Count == 0 && configuration.Alarms.Count == 0 && configuration.Scripts.Count == 0) { return new ValidationResult { Warnings = [ValidationEntry.Warning(ValidationCategory.FlatteningFailure, "Flattened configuration contains no attributes, alarms, or scripts.")] }; } return errors.Count > 0 ? new ValidationResult { Errors = errors } : ValidationResult.Success(); } /// /// Validates that there are no naming collisions across entity types. /// Canonical names must be unique within their entity type (attributes, alarms, scripts). /// /// The flattened configuration to validate. /// A with errors for each duplicate canonical name, or success. public static ValidationResult ValidateNamingCollisions(FlattenedConfiguration configuration) { var errors = new List(); CheckDuplicates(configuration.Attributes, a => a.CanonicalName, "Attribute", errors); CheckDuplicates(configuration.Alarms, a => a.CanonicalName, "Alarm", errors); CheckDuplicates(configuration.Scripts, s => s.CanonicalName, "Script", errors); return errors.Count > 0 ? new ValidationResult { Errors = errors } : ValidationResult.Success(); } /// /// Validates that all scripts compile successfully using the ScriptCompiler. /// /// The flattened configuration to validate. /// A with errors for each script that fails compilation. public ValidationResult ValidateScriptCompilation(FlattenedConfiguration configuration) { var errors = new List(); var warnings = new List(); foreach (var script in configuration.Scripts) { var result = _scriptCompiler.TryCompile(script.Code, script.CanonicalName); if (!result.IsSuccess) { errors.Add(ValidationEntry.Error(ValidationCategory.ScriptCompilation, $"Script '{script.CanonicalName}' failed compilation: {result.Error}", script.CanonicalName)); } } return new ValidationResult { Errors = errors, Warnings = warnings }; } /// /// Validates that alarm trigger configurations reference existing attributes. /// Alarm trigger configs are JSON with an "attributeName" field referencing a canonical attribute name. /// /// The flattened configuration to validate. /// A with errors for any alarm whose trigger references a missing attribute. public static ValidationResult ValidateAlarmTriggerReferences(FlattenedConfiguration configuration) { var errors = new List(); var attributeNames = new HashSet( configuration.Attributes.Select(a => a.CanonicalName), StringComparer.Ordinal); foreach (var alarm in configuration.Alarms) { if (string.IsNullOrWhiteSpace(alarm.TriggerConfiguration)) continue; var attrName = ExtractAttributeNameFromTriggerConfig(alarm.TriggerConfiguration); if (attrName != null && !attributeNames.Contains(attrName)) { errors.Add(ValidationEntry.Error(ValidationCategory.AlarmTriggerReference, $"Alarm '{alarm.CanonicalName}' references attribute '{attrName}' which does not exist in the flattened configuration.", alarm.CanonicalName)); } } return errors.Count > 0 ? new ValidationResult { Errors = errors } : ValidationResult.Success(); } /// /// Validates that script trigger configurations reference existing attributes. /// /// The flattened configuration to validate. /// A with errors for any script whose trigger references a missing attribute. public static ValidationResult ValidateScriptTriggerReferences(FlattenedConfiguration configuration) { var errors = new List(); var attributeNames = new HashSet( configuration.Attributes.Select(a => a.CanonicalName), StringComparer.Ordinal); foreach (var script in configuration.Scripts) { if (string.IsNullOrWhiteSpace(script.TriggerConfiguration)) continue; var attrName = ExtractAttributeNameFromTriggerConfig(script.TriggerConfiguration); if (attrName != null && !attributeNames.Contains(attrName)) { errors.Add(ValidationEntry.Error(ValidationCategory.ScriptTriggerReference, $"Script '{script.CanonicalName}' trigger references attribute '{attrName}' which does not exist in the flattened configuration.", script.CanonicalName)); } } return errors.Count > 0 ? new ValidationResult { Errors = errors } : ValidationResult.Success(); } /// /// Validates Expression-trigger scripts and alarms before deployment. /// /// For every script/alarm whose trigger type is "Expression" this performs three /// checks against the { "expression": "..." } trigger configuration: /// /// Blank expression → warning (the trigger will never fire). /// Syntax/compile check → error if the expression uses a forbidden API /// or does not compile as a bare boolean expression. Delegates to the shared /// authoritative analyzer (see and /// ) — a real Roslyn compile against the /// , not a string scan. /// Attribute-reference scan → error for any Attributes["X"] literal /// whose key is absent from the flattened configuration, mirroring /// for the structured triggers. /// /// /// The flattened configuration to validate. /// A with errors and warnings from all expression trigger checks. public static ValidationResult ValidateExpressionTriggers(FlattenedConfiguration configuration) { var errors = new List(); var warnings = new List(); var attributeNames = new HashSet( configuration.Attributes.Select(a => a.CanonicalName), StringComparer.Ordinal); foreach (var script in configuration.Scripts) { if (!IsExpressionTrigger(script.TriggerType)) continue; CheckExpressionTrigger( ValidationCategory.ScriptTriggerReference, "script", script.CanonicalName, script.TriggerConfiguration, attributeNames, errors, warnings); } foreach (var alarm in configuration.Alarms) { if (!IsExpressionTrigger(alarm.TriggerType)) continue; CheckExpressionTrigger( ValidationCategory.AlarmTriggerReference, "alarm", alarm.CanonicalName, alarm.TriggerConfiguration, attributeNames, errors, warnings); } return new ValidationResult { Errors = errors, Warnings = warnings }; } private static bool IsExpressionTrigger(string? triggerType) => string.Equals(triggerType, "Expression", StringComparison.OrdinalIgnoreCase); /// /// Runs the blank / syntax / attribute-reference checks for a single /// Expression-trigger entity and appends any findings to the shared lists. /// /// /// The to file every finding under /// ( for scripts, /// for alarms). The same /// category is used for blank, syntax, and attribute-reference findings so an /// alarm's syntax error is not miscategorised as script compilation. /// /// /// Human-readable entity-type label ("script"/"alarm") used in /// message text only. /// private static void CheckExpressionTrigger( ValidationCategory category, string entityLabel, string entityName, string? triggerConfigJson, HashSet attributeNames, List errors, List warnings) { var expression = ExtractExpressionFromTriggerConfig(triggerConfigJson); if (string.IsNullOrWhiteSpace(expression)) { warnings.Add(ValidationEntry.Warning(category, $"The {entityLabel} '{entityName}' has an expression trigger with no expression; it will never fire.", entityName)); return; } var syntaxError = CheckExpressionSyntax(expression); if (syntaxError != null) { errors.Add(ValidationEntry.Error(category, $"The {entityLabel} '{entityName}' expression trigger failed validation: {syntaxError}", entityName)); } foreach (var attrName in ExtractAttributeReferences(expression)) { if (!attributeNames.Contains(attrName)) { errors.Add(ValidationEntry.Error(category, $"The {entityLabel} '{entityName}' expression trigger references attribute '{attrName}' which does not exist in the flattened configuration.", entityName)); } } } /// /// Reads the "expression" string from a { "expression": "..." } trigger /// configuration. Returns null on malformed JSON or a missing key. /// /// The trigger configuration JSON to parse. /// The expression string, or null if absent or the JSON is malformed. internal static string? ExtractExpressionFromTriggerConfig(string? triggerConfigJson) { if (string.IsNullOrWhiteSpace(triggerConfigJson)) return null; try { using var doc = JsonDocument.Parse(triggerConfigJson); if (doc.RootElement.TryGetProperty("expression", out var prop) && prop.ValueKind == JsonValueKind.String) { return prop.GetString(); } } catch (JsonException) { // Not valid JSON — treated as a blank expression by the caller. } return null; } /// /// Authoritative syntax/trust check for a trigger expression. Delegates to the /// shared analyzer (same gate /// as ): a real forbidden-API verdict /// () /// followed by a real CSharpScript compile of the bare boolean expression against /// the globals. Returns an error message, or /// null when the expression is clean and compiles. /// /// The expression to check. /// A human-readable error message if the expression is invalid; null if well-formed. internal static string? CheckExpressionSyntax(string expression) { // Authoritative forbidden-API verdict first. var violations = ScriptTrustValidator.FindViolations(expression); if (violations.Count > 0) return $"uses forbidden API: {violations[0]}"; // Real compile of the bare boolean expression against the trigger globals. var errors = RoslynScriptCompiler.Compile(expression, typeof(TriggerCompileSurface)); if (errors.Count > 0) return $"is not a valid expression: {errors[0]}"; return null; } /// /// Scans an expression for Attributes["..."] string-literal accessor keys. /// Best-effort: only matches double-quoted literals (the form the editor emits) /// and skips keys built dynamically. /// /// The expression to scan for attribute references. /// The distinct attribute key strings found in self-attribute accessor positions. internal static IEnumerable ExtractAttributeReferences(string expression) { var seen = new HashSet(StringComparer.Ordinal); const string marker = "Attributes["; var index = 0; while ((index = expression.IndexOf(marker, index, StringComparison.Ordinal)) >= 0) { // Only treat this as a self-attribute reference when it is not a member // access. A bare `Attributes["X"]` resolves against the flattened // configuration; `Children["Pump"].Attributes["X"]` and // `Parent.Attributes["X"]` are member accesses (preceded by '.') whose // dotted/composed canonical names cannot be checked against the flat // self-attribute set — skip them rather than emit a false positive. if (index > 0 && expression[index - 1] == '.') { index += marker.Length; continue; } var cursor = index + marker.Length; // Skip whitespace between '[' and the literal. while (cursor < expression.Length && char.IsWhiteSpace(expression[cursor])) cursor++; if (cursor < expression.Length && expression[cursor] == '"') { var keyStart = cursor + 1; var keyEnd = keyStart; while (keyEnd < expression.Length && expression[keyEnd] != '"') { if (expression[keyEnd] == '\\') keyEnd++; // skip escaped char keyEnd++; } if (keyEnd < expression.Length) { var key = expression.Substring(keyStart, keyEnd - keyStart); if (key.Length > 0 && seen.Add(key)) yield return key; } } index += marker.Length; } } /// /// Validates connection bindings on data-sourced attributes. Only DATA-SOURCED /// attributes ( != null) /// require a binding; static attributes are never flagged. /// /// M2.8 (#23): the severity is context-dependent (see ). /// At template design time (enforce == false) an unbound data-sourced /// attribute is legitimate (bindings are set later) so it is only a non-blocking /// Warning. On the deploy path (enforce == true) an unbound /// data-sourced attribute is a deploy-gating Error, and — when /// is supplied — a binding to a connection /// that does not exist on the target site is also an Error. /// /// The flattened configuration to validate. /// /// true on the deploy path (unbound → Error + "exists at site" check); /// false at design time (unbound → Warning only). Defaults to false /// so design-time validation stays non-blocking. /// /// /// Optional set of data-connection names that actually exist on the target site. /// When non-null and is true, every bound /// connection name is checked against this set. null skips the "exists at /// site" check. /// /// A with the binding findings at the appropriate severity. public static ValidationResult ValidateConnectionBindingCompleteness( FlattenedConfiguration configuration, bool enforce = false, IReadOnlySet? siteConnectionNames = null) { var errors = new List(); var warnings = new List(); foreach (var attr in configuration.Attributes) { // Only data-sourced attributes participate in binding validation. if (attr.DataSourceReference == null) continue; if (attr.BoundDataConnectionId == null) { // Unbound data-sourced attribute. At deploy time this gates the // deployment; at design time the binding is set later, so it is // only advisory. // // NOTE: this branch fires for TWO distinct cases that are // indistinguishable post-flattening: // 1. The user genuinely never set a binding. // 2. The user set a binding, but FlatteningService.ApplyConnectionBindings // silently dropped it because the stored DataConnectionId no longer // resolves to any loaded site DataConnection (i.e. the connection was // deleted after the binding was created). In that case the flattener // leaves BoundDataConnectionId == null, and the attribute falls into // this same "unbound → Error" path. // The error message covers both cases; no behavioral change is needed. if (enforce) { errors.Add(ValidationEntry.Error(ValidationCategory.ConnectionBinding, $"Attribute '{attr.CanonicalName}' has a data source reference but no connection binding.", attr.CanonicalName)); } else { warnings.Add(ValidationEntry.Warning(ValidationCategory.ConnectionBinding, $"Attribute '{attr.CanonicalName}' has a data source reference but no connection binding.", attr.CanonicalName)); } // Skip the "exists at site" check below — it only applies to bound attributes. continue; } // The attribute IS bound. On the deploy path, verify the bound connection // actually exists on the target site (resolve against the site's connection // set, not just name presence in the config). A binding pointing at a // non-existent/stale site connection is a deploy-gating Error. if (enforce && siteConnectionNames != null && attr.BoundDataConnectionName != null && !siteConnectionNames.Contains(attr.BoundDataConnectionName)) { errors.Add(ValidationEntry.Error(ValidationCategory.ConnectionBinding, $"Attribute '{attr.CanonicalName}' is bound to data connection '{attr.BoundDataConnectionName}' " + "which does not exist on the target site.", attr.CanonicalName)); } } return new ValidationResult { Errors = errors, Warnings = warnings }; } private static void CheckDuplicates( IReadOnlyList items, Func getName, string entityType, List errors) { var seen = new HashSet(StringComparer.Ordinal); foreach (var item in items) { var name = getName(item); if (!seen.Add(name)) { errors.Add(ValidationEntry.Error(ValidationCategory.NamingCollision, $"{entityType} naming collision: '{name}' appears more than once.", name)); } } } /// /// Extracts the attribute name from a trigger configuration JSON. /// /// The trigger configuration JSON to parse. /// The attribute name from the attributeName or legacy attribute key, or null if absent or malformed. internal static string? ExtractAttributeNameFromTriggerConfig(string triggerConfigJson) { // Accept both keys to stay consistent with FlatteningService.PrefixTriggerAttribute, // AlarmActor.ParseEvalConfig and AlarmTriggerConfigCodec. Old data may still use // "attribute"; the UI codec writes the canonical "attributeName". try { using var doc = JsonDocument.Parse(triggerConfigJson); if (doc.RootElement.TryGetProperty("attributeName", out var prop)) return prop.GetString(); if (doc.RootElement.TryGetProperty("attribute", out var legacyProp)) return legacyProp.GetString(); } catch (JsonException) { // Not valid JSON, ignore } return null; } /// /// Extracts the four HiLo setpoints from a trigger configuration JSON. /// Any unset (or non-numeric) setpoint comes back as null. Returns /// all-nulls on malformed JSON — callers should treat that as "nothing to /// validate" and let other checks surface the deeper problem. /// /// The trigger configuration JSON to parse. /// A record with the parsed setpoint values; any missing or non-numeric value is null. internal static HiLoSetpoints ExtractHiLoSetpoints(string triggerConfigJson) { try { using var doc = JsonDocument.Parse(triggerConfigJson); var root = doc.RootElement; return new HiLoSetpoints( LoLo: ReadDouble(root, "loLo"), Lo: ReadDouble(root, "lo"), Hi: ReadDouble(root, "hi"), HiHi: ReadDouble(root, "hiHi"), LoLoDeadband: ReadDouble(root, "loLoDeadband"), LoDeadband: ReadDouble(root, "loDeadband"), HiDeadband: ReadDouble(root, "hiDeadband"), HiHiDeadband: ReadDouble(root, "hiHiDeadband")); } catch (JsonException) { return new HiLoSetpoints(null, null, null, null, null, null, null, null); } } private static double? ReadDouble(JsonElement el, string name) { if (!el.TryGetProperty(name, out var p)) return null; return p.ValueKind switch { JsonValueKind.Number => p.GetDouble(), JsonValueKind.String when double.TryParse(p.GetString(), System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var v) => v, _ => null }; } } internal readonly record struct HiLoSetpoints( double? LoLo, double? Lo, double? Hi, double? HiHi, double? LoLoDeadband = null, double? LoDeadband = null, double? HiDeadband = null, double? HiHiDeadband = null);