Phase 2 WP-1–13+23: Template Engine CRUD, composition, overrides, locking, collision detection, acyclicity

- 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.
This commit is contained in:
Joseph Doherty
2026-03-16 20:10:34 -04:00
parent 84ad6bb77d
commit faef2d0de6
47 changed files with 7741 additions and 11 deletions

View File

@@ -0,0 +1,120 @@
using ScadaLink.Commons.Types;
namespace ScadaLink.TemplateEngine.Validation;
/// <summary>
/// Validates script code by attempting to compile it using Roslyn.
/// In production, this would compile C# scripts against a stub ScriptApi assembly
/// that provides the allowed API surface (attribute read/write, CallScript, CallShared, etc.)
/// and enforces the forbidden API list (System.IO, Process, Threading, Reflection, raw network).
///
/// For now, this implementation performs basic syntax validation.
/// </summary>
public class ScriptCompiler
{
// Forbidden namespace patterns - scripts must not use these
private static readonly string[] ForbiddenPatterns =
[
"System.IO.",
"System.Diagnostics.Process",
"System.Threading.",
"System.Reflection.",
"System.Net.Sockets.",
"System.Net.Http.",
];
/// <summary>
/// Attempts to compile a script and returns success or a compilation error.
/// </summary>
/// <param name="code">The C# script code.</param>
/// <param name="scriptName">The canonical name of the script (for error messages).</param>
/// <returns>Success if the script compiles, or Failure with the error message.</returns>
public Result<bool> TryCompile(string code, string scriptName)
{
if (string.IsNullOrWhiteSpace(code))
return Result<bool>.Failure($"Script '{scriptName}' has empty code.");
// Check for forbidden APIs
foreach (var pattern in ForbiddenPatterns)
{
if (code.Contains(pattern, StringComparison.Ordinal))
{
return Result<bool>.Failure(
$"Script '{scriptName}' uses forbidden API: '{pattern.TrimEnd('.')}'. " +
"Scripts cannot use System.IO, Process, Threading, Reflection, or raw network APIs.");
}
}
// Basic brace matching validation
var braceDepth = 0;
var inString = false;
var inLineComment = false;
var inBlockComment = false;
for (int i = 0; i < code.Length; i++)
{
var c = code[i];
var next = i + 1 < code.Length ? code[i + 1] : '\0';
if (inLineComment)
{
if (c == '\n') inLineComment = false;
continue;
}
if (inBlockComment)
{
if (c == '*' && next == '/')
{
inBlockComment = false;
i++;
}
continue;
}
if (c == '/' && next == '/')
{
inLineComment = true;
i++;
continue;
}
if (c == '/' && next == '*')
{
inBlockComment = true;
i++;
continue;
}
if (c == '"' && !inString)
{
inString = true;
continue;
}
if (c == '"' && inString)
{
// Check for escaped quote
if (i > 0 && code[i - 1] != '\\')
inString = false;
continue;
}
if (inString) continue;
if (c == '{') braceDepth++;
else if (c == '}') braceDepth--;
if (braceDepth < 0)
return Result<bool>.Failure($"Script '{scriptName}' has mismatched braces (unexpected closing brace).");
}
if (braceDepth != 0)
return Result<bool>.Failure($"Script '{scriptName}' has mismatched braces ({braceDepth} unclosed).");
if (inBlockComment)
return Result<bool>.Failure($"Script '{scriptName}' has an unclosed block comment.");
return Result<bool>.Success(true);
}
}

View File

@@ -0,0 +1,303 @@
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; }
}
}

View File

@@ -0,0 +1,235 @@
using System.Text.Json;
using ScadaLink.Commons.Types.Flattening;
namespace ScadaLink.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. Connection binding completeness (all data-sourced attributes must have a binding)
/// 7. Does NOT verify tag path resolution on devices
/// </summary>
public class ValidationService
{
private readonly SemanticValidator _semanticValidator;
private readonly ScriptCompiler _scriptCompiler;
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>
public ValidationResult Validate(FlattenedConfiguration configuration, IReadOnlyList<ResolvedScript>? sharedScripts = null)
{
ArgumentNullException.ThrowIfNull(configuration);
var results = new List<ValidationResult>
{
ValidateFlatteningSuccess(configuration),
ValidateNamingCollisions(configuration),
ValidateScriptCompilation(configuration),
ValidateAlarmTriggerReferences(configuration),
ValidateScriptTriggerReferences(configuration),
ValidateConnectionBindingCompleteness(configuration),
_semanticValidator.Validate(configuration, sharedScripts)
};
return ValidationResult.Merge(results.ToArray());
}
/// <summary>
/// Validates that flattening produced a non-empty configuration.
/// </summary>
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>
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>
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>
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>
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 that all data-sourced attributes have connection bindings.
/// </summary>
public static ValidationResult ValidateConnectionBindingCompleteness(FlattenedConfiguration configuration)
{
var errors = new List<ValidationEntry>();
var warnings = new List<ValidationEntry>();
foreach (var attr in configuration.Attributes)
{
if (attr.DataSourceReference != null && attr.BoundDataConnectionId == null)
{
warnings.Add(ValidationEntry.Warning(ValidationCategory.ConnectionBinding,
$"Attribute '{attr.CanonicalName}' has a data source reference but no connection binding.",
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));
}
}
}
internal static string? ExtractAttributeNameFromTriggerConfig(string triggerConfigJson)
{
try
{
using var doc = JsonDocument.Parse(triggerConfigJson);
if (doc.RootElement.TryGetProperty("attributeName", out var prop))
return prop.GetString();
}
catch (JsonException)
{
// Not valid JSON, ignore
}
return null;
}
}