21b801b71f
Add code comments in ValidateConnectionBindingCompleteness explaining that the unbound-attribute branch also covers the silently-dropped stale-binding case (cross-reference FlatteningService.ApplyConnectionBindings), and that the `continue` skips the exists-at-site check for unbound attrs. Add two new tests: - FlatteningPipelineConnectionBindingTests: stale DataConnectionId (999) not present in site connections → flattener drops it silently → validator reports ConnectionBinding Error, IsValid false. - ValidationServiceTests: enforce:true + siteConnectionNames:null on a properly-bound attribute → no ConnectionBinding error (exists-at-site check stays inert when site set is not supplied).
720 lines
32 KiB
C#
720 lines
32 KiB
C#
using System.Text.Json;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.TemplateEngine.Validation;
|
|
|
|
/// <summary>
|
|
/// 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. Does NOT verify tag path resolution on devices
|
|
/// </summary>
|
|
public class ValidationService
|
|
{
|
|
private readonly SemanticValidator _semanticValidator;
|
|
private readonly ScriptCompiler _scriptCompiler;
|
|
|
|
/// <summary>
|
|
/// Initializes a new instance of the ValidationService with the specified dependencies.
|
|
/// </summary>
|
|
/// <param name="semanticValidator">The semantic validator for configuration validation.</param>
|
|
/// <param name="scriptCompiler">The script compiler for validating script code.</param>
|
|
public ValidationService(SemanticValidator semanticValidator, ScriptCompiler scriptCompiler)
|
|
{
|
|
_semanticValidator = semanticValidator;
|
|
_scriptCompiler = scriptCompiler;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Convenience constructor that creates default dependencies.
|
|
/// </summary>
|
|
public ValidationService() : this(new SemanticValidator(), new ScriptCompiler())
|
|
{
|
|
}
|
|
|
|
/// <summary>
|
|
/// Runs the full validation pipeline on a flattened configuration.
|
|
/// </summary>
|
|
/// <param name="configuration">The flattened configuration to validate.</param>
|
|
/// <param name="sharedScripts">Optional list of shared scripts for validation context.</param>
|
|
/// <param name="alarmCapableConnectionNames">
|
|
/// Optional set of site data-connection names whose protocol resolves to an
|
|
/// alarm-capable adapter (see
|
|
/// <see cref="Commons.Interfaces.Protocol.AlarmCapableProtocols"/>). When supplied,
|
|
/// the semantic validator gates every native-alarm-source binding against it.
|
|
/// <c>null</c> skips the capability check (its absence makes the check inert).
|
|
/// </param>
|
|
/// <param name="enforceConnectionBindings">
|
|
/// M2.8 (#23): controls the severity of the connection-binding-completeness check.
|
|
/// <para>
|
|
/// <c>false</c> (default) — template DESIGN-TIME: a data-sourced attribute that is
|
|
/// not yet bound produces only a non-blocking <c>Warning</c>. Bindings are set later,
|
|
/// at instance/deploy time, so an unbound data-sourced template attribute is legitimate
|
|
/// here (see <see cref="ManagementService"/>'s ValidateTemplate path, which builds a
|
|
/// config straight from raw template members with no bindings).
|
|
/// </para>
|
|
/// <para>
|
|
/// <c>true</c> — DEPLOY path (<see cref="DeploymentManager"/>'s FlatteningPipeline):
|
|
/// an unbound data-sourced attribute becomes a deploy-gating <c>Error</c> (IsValid false),
|
|
/// and — when <paramref name="siteConnectionNames"/> is supplied — a binding pointing at a
|
|
/// connection that does not exist on the target site is also an <c>Error</c>.
|
|
/// </para>
|
|
/// </param>
|
|
/// <param name="siteConnectionNames">
|
|
/// 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 <paramref name="alarmCapableConnectionNames"/>). When supplied (and
|
|
/// <paramref name="enforceConnectionBindings"/> is <c>true</c>), every bound
|
|
/// connection is checked against this set so a binding to a phantom/stale connection
|
|
/// is caught. <c>null</c> skips the "exists at site" half (it stays inert).
|
|
/// </param>
|
|
/// <returns>A merged <see cref="ValidationResult"/> aggregating all pipeline stage outcomes.</returns>
|
|
public ValidationResult Validate(
|
|
FlattenedConfiguration configuration,
|
|
IReadOnlyList<ResolvedScript>? sharedScripts = null,
|
|
IReadOnlySet<string>? alarmCapableConnectionNames = null,
|
|
bool enforceConnectionBindings = false,
|
|
IReadOnlySet<string>? siteConnectionNames = null)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(configuration);
|
|
|
|
var results = new List<ValidationResult>
|
|
{
|
|
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());
|
|
}
|
|
|
|
/// <summary>
|
|
/// Validates that flattening produced a non-empty configuration.
|
|
/// </summary>
|
|
/// <param name="configuration">The flattened configuration to validate.</param>
|
|
/// <returns>A <see cref="ValidationResult"/> with errors or warnings if the configuration is empty or missing a name; otherwise success.</returns>
|
|
public static ValidationResult ValidateFlatteningSuccess(FlattenedConfiguration configuration)
|
|
{
|
|
var errors = new List<ValidationEntry>();
|
|
|
|
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();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Validates that there are no naming collisions across entity types.
|
|
/// Canonical names must be unique within their entity type (attributes, alarms, scripts).
|
|
/// </summary>
|
|
/// <param name="configuration">The flattened configuration to validate.</param>
|
|
/// <returns>A <see cref="ValidationResult"/> with errors for each duplicate canonical name, or success.</returns>
|
|
public static ValidationResult ValidateNamingCollisions(FlattenedConfiguration configuration)
|
|
{
|
|
var errors = new List<ValidationEntry>();
|
|
|
|
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();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Validates that all scripts compile successfully using the ScriptCompiler.
|
|
/// </summary>
|
|
/// <param name="configuration">The flattened configuration to validate.</param>
|
|
/// <returns>A <see cref="ValidationResult"/> with errors for each script that fails compilation.</returns>
|
|
public ValidationResult ValidateScriptCompilation(FlattenedConfiguration configuration)
|
|
{
|
|
var errors = new List<ValidationEntry>();
|
|
var warnings = new List<ValidationEntry>();
|
|
|
|
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 };
|
|
}
|
|
|
|
/// <summary>
|
|
/// Validates that alarm trigger configurations reference existing attributes.
|
|
/// Alarm trigger configs are JSON with an "attributeName" field referencing a canonical attribute name.
|
|
/// </summary>
|
|
/// <param name="configuration">The flattened configuration to validate.</param>
|
|
/// <returns>A <see cref="ValidationResult"/> with errors for any alarm whose trigger references a missing attribute.</returns>
|
|
public static ValidationResult ValidateAlarmTriggerReferences(FlattenedConfiguration configuration)
|
|
{
|
|
var errors = new List<ValidationEntry>();
|
|
var attributeNames = new HashSet<string>(
|
|
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();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Validates that script trigger configurations reference existing attributes.
|
|
/// </summary>
|
|
/// <param name="configuration">The flattened configuration to validate.</param>
|
|
/// <returns>A <see cref="ValidationResult"/> with errors for any script whose trigger references a missing attribute.</returns>
|
|
public static ValidationResult ValidateScriptTriggerReferences(FlattenedConfiguration configuration)
|
|
{
|
|
var errors = new List<ValidationEntry>();
|
|
var attributeNames = new HashSet<string>(
|
|
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();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Validates Expression-trigger scripts and alarms before deployment.
|
|
///
|
|
/// For every script/alarm whose trigger type is "Expression" this performs three
|
|
/// checks against the <c>{ "expression": "..." }</c> trigger configuration:
|
|
/// <list type="bullet">
|
|
/// <item>Blank expression → warning (the trigger will never fire).</item>
|
|
/// <item>Syntax check → error if the expression uses a forbidden API or has
|
|
/// unbalanced brackets/quotes. The TemplateEngine project does not reference a
|
|
/// Roslyn compiler (see <see cref="ScriptCompiler"/>), so this mirrors that
|
|
/// string-based syntax check rather than a full compile.</item>
|
|
/// <item>Attribute-reference scan → error for any <c>Attributes["X"]</c> literal
|
|
/// whose key is absent from the flattened configuration, mirroring
|
|
/// <see cref="ValidateScriptTriggerReferences"/> for the structured triggers.</item>
|
|
/// </list>
|
|
/// </summary>
|
|
/// <param name="configuration">The flattened configuration to validate.</param>
|
|
/// <returns>A <see cref="ValidationResult"/> with errors and warnings from all expression trigger checks.</returns>
|
|
public static ValidationResult ValidateExpressionTriggers(FlattenedConfiguration configuration)
|
|
{
|
|
var errors = new List<ValidationEntry>();
|
|
var warnings = new List<ValidationEntry>();
|
|
var attributeNames = new HashSet<string>(
|
|
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);
|
|
|
|
/// <summary>
|
|
/// Runs the blank / syntax / attribute-reference checks for a single
|
|
/// Expression-trigger entity and appends any findings to the shared lists.
|
|
/// </summary>
|
|
/// <param name="category">
|
|
/// The <see cref="ValidationCategory"/> to file every finding under
|
|
/// (<see cref="ValidationCategory.ScriptTriggerReference"/> for scripts,
|
|
/// <see cref="ValidationCategory.AlarmTriggerReference"/> 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.
|
|
/// </param>
|
|
/// <param name="entityLabel">
|
|
/// Human-readable entity-type label (<c>"script"</c>/<c>"alarm"</c>) used in
|
|
/// message text only.
|
|
/// </param>
|
|
private static void CheckExpressionTrigger(
|
|
ValidationCategory category,
|
|
string entityLabel,
|
|
string entityName,
|
|
string? triggerConfigJson,
|
|
HashSet<string> attributeNames,
|
|
List<ValidationEntry> errors,
|
|
List<ValidationEntry> 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));
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Reads the "expression" string from a <c>{ "expression": "..." }</c> trigger
|
|
/// configuration. Returns <c>null</c> on malformed JSON or a missing key.
|
|
/// </summary>
|
|
/// <param name="triggerConfigJson">The trigger configuration JSON to parse.</param>
|
|
/// <returns>The expression string, or <c>null</c> if absent or the JSON is malformed.</returns>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Lightweight string-based syntax check for a trigger expression. Mirrors the
|
|
/// approach in <see cref="ScriptCompiler"/> (the TemplateEngine project has no
|
|
/// Roslyn compiler reference): rejects forbidden APIs and unbalanced
|
|
/// brackets/quotes. Returns an error message, or <c>null</c> when the expression
|
|
/// looks well-formed.
|
|
/// </summary>
|
|
/// <param name="expression">The expression to check for syntax errors.</param>
|
|
/// <returns>A human-readable error message if the expression is invalid; <c>null</c> if well-formed.</returns>
|
|
internal static string? CheckExpressionSyntax(string expression)
|
|
{
|
|
// Advisory forbidden-API scan (TemplateEngine-006): code-region-aware so
|
|
// the inert text inside a string/comment is not flagged, but still a
|
|
// substring match — not an authoritative boundary. See ScriptCompiler.
|
|
foreach (var pattern in ScriptCompiler.ForbiddenPatterns)
|
|
{
|
|
if (CSharpDelimiterScanner.ContainsInCode(expression, pattern))
|
|
{
|
|
return $"uses forbidden API '{pattern.TrimEnd('.')}'. " +
|
|
"Trigger expressions cannot use System.IO, Process, Threading, Reflection, or raw network APIs.";
|
|
}
|
|
}
|
|
|
|
var parenDepth = 0;
|
|
var bracketDepth = 0;
|
|
var braceDepth = 0;
|
|
var inString = false;
|
|
var inChar = false;
|
|
var inLineComment = false;
|
|
var inBlockComment = false;
|
|
|
|
for (int i = 0; i < expression.Length; i++)
|
|
{
|
|
var c = expression[i];
|
|
var next = i + 1 < expression.Length ? expression[i + 1] : '\0';
|
|
|
|
if (inLineComment)
|
|
{
|
|
if (c == '\n') inLineComment = false;
|
|
continue;
|
|
}
|
|
|
|
if (inBlockComment)
|
|
{
|
|
if (c == '*' && next == '/')
|
|
{
|
|
inBlockComment = false;
|
|
i++;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (inString)
|
|
{
|
|
if (c == '\\') { i++; continue; }
|
|
if (c == '"') inString = false;
|
|
continue;
|
|
}
|
|
|
|
if (inChar)
|
|
{
|
|
if (c == '\\') { i++; continue; }
|
|
if (c == '\'') inChar = false;
|
|
continue;
|
|
}
|
|
|
|
if (c == '/' && next == '/')
|
|
{
|
|
inLineComment = true;
|
|
i++;
|
|
continue;
|
|
}
|
|
|
|
if (c == '/' && next == '*')
|
|
{
|
|
inBlockComment = true;
|
|
i++;
|
|
continue;
|
|
}
|
|
|
|
switch (c)
|
|
{
|
|
case '"': inString = true; break;
|
|
case '\'': inChar = true; break;
|
|
case '(': parenDepth++; break;
|
|
case ')':
|
|
parenDepth--;
|
|
if (parenDepth < 0) return "mismatched parentheses (unexpected ')').";
|
|
break;
|
|
case '[': bracketDepth++; break;
|
|
case ']':
|
|
bracketDepth--;
|
|
if (bracketDepth < 0) return "mismatched brackets (unexpected ']').";
|
|
break;
|
|
case '{': braceDepth++; break;
|
|
case '}':
|
|
braceDepth--;
|
|
if (braceDepth < 0) return "mismatched braces (unexpected '}').";
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (inBlockComment) return "unterminated block comment.";
|
|
if (inString) return "unterminated string literal.";
|
|
if (inChar) return "unterminated character literal.";
|
|
if (parenDepth != 0) return $"mismatched parentheses ({parenDepth} unclosed).";
|
|
if (bracketDepth != 0) return $"mismatched brackets ({bracketDepth} unclosed).";
|
|
if (braceDepth != 0) return $"mismatched braces ({braceDepth} unclosed).";
|
|
|
|
return null;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Scans an expression for <c>Attributes["..."]</c> string-literal accessor keys.
|
|
/// Best-effort: only matches double-quoted literals (the form the editor emits)
|
|
/// and skips keys built dynamically.
|
|
/// </summary>
|
|
/// <param name="expression">The expression to scan for attribute references.</param>
|
|
/// <returns>The distinct attribute key strings found in self-attribute accessor positions.</returns>
|
|
internal static IEnumerable<string> ExtractAttributeReferences(string expression)
|
|
{
|
|
var seen = new HashSet<string>(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;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Validates connection bindings on data-sourced attributes. Only DATA-SOURCED
|
|
/// attributes (<see cref="ResolvedAttribute.DataSourceReference"/> != <c>null</c>)
|
|
/// require a binding; static attributes are never flagged.
|
|
///
|
|
/// M2.8 (#23): the severity is context-dependent (see <paramref name="enforce"/>).
|
|
/// At template design time (<c>enforce == false</c>) an unbound data-sourced
|
|
/// attribute is legitimate (bindings are set later) so it is only a non-blocking
|
|
/// <c>Warning</c>. On the deploy path (<c>enforce == true</c>) an unbound
|
|
/// data-sourced attribute is a deploy-gating <c>Error</c>, and — when
|
|
/// <paramref name="siteConnectionNames"/> is supplied — a binding to a connection
|
|
/// that does not exist on the target site is also an <c>Error</c>.
|
|
/// </summary>
|
|
/// <param name="configuration">The flattened configuration to validate.</param>
|
|
/// <param name="enforce">
|
|
/// <c>true</c> on the deploy path (unbound → Error + "exists at site" check);
|
|
/// <c>false</c> at design time (unbound → Warning only). Defaults to <c>false</c>
|
|
/// so design-time validation stays non-blocking.
|
|
/// </param>
|
|
/// <param name="siteConnectionNames">
|
|
/// Optional set of data-connection names that actually exist on the target site.
|
|
/// When non-<c>null</c> and <paramref name="enforce"/> is <c>true</c>, every bound
|
|
/// connection name is checked against this set. <c>null</c> skips the "exists at
|
|
/// site" check.
|
|
/// </param>
|
|
/// <returns>A <see cref="ValidationResult"/> with the binding findings at the appropriate severity.</returns>
|
|
public static ValidationResult ValidateConnectionBindingCompleteness(
|
|
FlattenedConfiguration configuration,
|
|
bool enforce = false,
|
|
IReadOnlySet<string>? siteConnectionNames = null)
|
|
{
|
|
var errors = new List<ValidationEntry>();
|
|
var warnings = new List<ValidationEntry>();
|
|
|
|
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<T>(
|
|
IReadOnlyList<T> items,
|
|
Func<T, string> getName,
|
|
string entityType,
|
|
List<ValidationEntry> errors)
|
|
{
|
|
var seen = new HashSet<string>(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));
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Extracts the attribute name from a trigger configuration JSON.
|
|
/// </summary>
|
|
/// <param name="triggerConfigJson">The trigger configuration JSON to parse.</param>
|
|
/// <returns>The attribute name from the <c>attributeName</c> or legacy <c>attribute</c> key, or <c>null</c> if absent or malformed.</returns>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Extracts the four HiLo setpoints from a trigger configuration JSON.
|
|
/// Any unset (or non-numeric) setpoint comes back as <c>null</c>. Returns
|
|
/// all-nulls on malformed JSON — callers should treat that as "nothing to
|
|
/// validate" and let other checks surface the deeper problem.
|
|
/// </summary>
|
|
/// <param name="triggerConfigJson">The trigger configuration JSON to parse.</param>
|
|
/// <returns>A <see cref="HiLoSetpoints"/> record with the parsed setpoint values; any missing or non-numeric value is <c>null</c>.</returns>
|
|
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);
|