faef2d0de6
- WP-23: ITemplateEngineRepository full EF Core implementation - WP-1: Template CRUD with deletion constraints (instances, children, compositions) - WP-2–4: Attribute, alarm, script definitions with lock flags and override granularity - WP-5: Shared script CRUD with syntax validation - WP-6–7: Composition with recursive nesting and canonical naming - WP-8–11: Override granularity, locking rules, inheritance/composition scope - WP-12: Naming collision detection on canonical names (recursive) - WP-13: Graph acyclicity (inheritance + composition cycles) Core services: TemplateService, SharedScriptService, TemplateResolver, LockEnforcer, CollisionDetector, CycleDetector. 358 tests pass.
304 lines
11 KiB
C#
304 lines
11 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 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));
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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);
|
|
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; }
|
|
}
|
|
}
|