using System.Text.Json; using ZB.MOM.WW.ScadaBridge.Commons.Types; using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening; namespace ZB.MOM.WW.ScadaBridge.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. /// Connection names that support alarm subscriptions; used to validate native alarm source bindings. /// A containing all semantic errors and warnings found. public ValidationResult Validate( FlattenedConfiguration configuration, IReadOnlyList? sharedScripts = null, IReadOnlySet? alarmCapableConnectionNames = null) { var errors = new List(); var warnings = new List(); var scriptNames = new HashSet( configuration.Scripts.Select(s => s.CanonicalName), StringComparer.Ordinal); // Composition-delegated CallScript: a machine script may invoke a composed // child's script via Children["X"].CallScript("Y") (often with a DYNAMIC child // name, e.g. Children[side + "MESReceiver"]). ExtractCalls captures only the // literal leaf "Y"; the actual flattened script is the composed canonical // "X.Y". Since the child segment is dynamic it cannot be statically resolved, // so we accept the call as existing when ANY composed script has that leaf // name. The positional arg-count/type checks already self-skip for these // (their canonical name "X.Y" is not the literal key "Y" in the param map). var composedLeafNames = new HashSet( configuration.Scripts .Select(s => s.CanonicalName) .Where(n => n.Contains('.')) .Select(n => n[(n.LastIndexOf('.') + 1)..]), 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); } // List-attribute type semantics (MV-5): element-type cardinality + default // value parseability. Trigger-operand rejection (rule 3) is handled below // by the existing NumericDataTypes guard (List is never numeric). ValidateListAttributes(configuration, errors); // 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); ValidateCallReturnType(script.CanonicalName, call, sharedReturnMap, errors); } } else { // CallScript targets must reference an existing instance script — // either a same-scope sibling (canonical name) or a composition- // delegated child script (leaf-name match; see composedLeafNames). if (!scriptNames.Contains(call.TargetName) && !composedLeafNames.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); ValidateCallReturnType(script.CanonicalName, call, scriptReturnMap, 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)); } } // Native alarm source bindings: connection + source reference must be // present, and (when the alarm-capable connection set is supplied) the // connection must resolve to an alarm-capable site data connection. foreach (var nativeSource in configuration.NativeAlarmSources) { if (string.IsNullOrWhiteSpace(nativeSource.SourceReference)) { errors.Add(ValidationEntry.Error(ValidationCategory.NativeAlarmSourceInvalid, $"Native alarm source '{nativeSource.CanonicalName}' has an empty source reference.", nativeSource.CanonicalName)); } if (string.IsNullOrWhiteSpace(nativeSource.ConnectionName)) { errors.Add(ValidationEntry.Error(ValidationCategory.NativeAlarmSourceInvalid, $"Native alarm source '{nativeSource.CanonicalName}' has no data connection.", nativeSource.CanonicalName)); } else if (alarmCapableConnectionNames is not null && !alarmCapableConnectionNames.Contains(nativeSource.ConnectionName)) { errors.Add(ValidationEntry.Error(ValidationCategory.NativeAlarmSourceInvalid, $"Native alarm source '{nativeSource.CanonicalName}' references connection '{nativeSource.ConnectionName}' which is not an alarm-capable data connection on this site.", nativeSource.CanonicalName)); } } return new ValidationResult { Errors = errors, Warnings = warnings }; } /// /// MV-5 — semantic validation of List-attribute type configuration. Two rules: /// /// Element-type cardinality. A attribute /// must carry a non-empty that is /// a valid element scalar (see ); /// a non-List attribute must NOT carry an element type. /// Default-value parseability. A non-empty authored default /// on a List attribute must /// without throwing. /// /// Attributes whose doesn't parse to a /// known are skipped here (their data type is not "List", /// so only the "no element type" half could apply, and an unparseable type is a /// separate concern not introduced by this feature). /// private static void ValidateListAttributes( FlattenedConfiguration configuration, List errors) { foreach (var attr in configuration.Attributes) { var isList = string.Equals(attr.DataType, nameof(DataType.List), StringComparison.OrdinalIgnoreCase); var hasElementType = !string.IsNullOrWhiteSpace(attr.ElementDataType); // ── Rule 1: element-type cardinality ───────────────────────────── if (!isList) { if (hasElementType) { errors.Add(ValidationEntry.Error(ValidationCategory.MissingMetadata, $"Attribute '{attr.CanonicalName}' has data type '{attr.DataType}' but declares an element type '{attr.ElementDataType}'; element types are only valid on List attributes.", attr.CanonicalName)); } continue; // Non-List attributes have no list-specific value to check. } if (!hasElementType) { errors.Add(ValidationEntry.Error(ValidationCategory.MissingMetadata, $"List attribute '{attr.CanonicalName}' must declare an element type (one of String, Int32, Float, Double, Boolean, DateTime).", attr.CanonicalName)); continue; // Without an element type we can't validate the default value. } if (!Enum.TryParse(attr.ElementDataType, ignoreCase: true, out var elementType) || !AttributeValueCodec.IsValidElementType(elementType)) { errors.Add(ValidationEntry.Error(ValidationCategory.MissingMetadata, $"List attribute '{attr.CanonicalName}' has element type '{attr.ElementDataType}', which is not a valid element scalar (one of String, Int32, Float, Double, Boolean, DateTime).", attr.CanonicalName)); continue; // A bad element type makes the default-value check meaningless. } // ── Rule 2: default-value parseability ─────────────────────────── if (!string.IsNullOrWhiteSpace(attr.Value)) { try { AttributeValueCodec.Decode(attr.Value, DataType.List, elementType); } catch (FormatException ex) { errors.Add(ValidationEntry.Error(ValidationCategory.MissingMetadata, $"List attribute '{attr.CanonicalName}' has a default value that is not a valid list of '{elementType}': {ex.Message}", attr.CanonicalName)); } } } } 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)); // Count mismatch already reported — positional type matching below // would be misaligned, so don't compound the noise. return; } ValidateArgumentTypes(callerName, call, expectedParams, errors); } /// /// #21 — Argument-type validation. Compares each positionally-matched call /// argument expression against the target's declared parameter type and /// flags only CLEAR cross-category mismatches. /// /// Conservatism (false-positive avoidance) — a parameter is checked only /// when BOTH sides are confidently known: /// /// Declared type must normalize to a known primitive (String, Integer, /// Float, Boolean). Object/List/unknown declarations accept /// anything — never flagged. /// Argument expression type must be inferable from a literal /// (string/char, integer, decimal, true/false). Variables, /// member access, method/await chains, null, casts, object/array /// initializers, and anything else infer to Unknown and are never flagged. /// Integer⇄Float is treated as compatible (numeric widening) — never /// flagged. /// /// private static void ValidateArgumentTypes( string callerName, CallTarget call, List expectedParams, List errors) { // Argument expressions are aligned 1:1 with parameters here (count was // verified equal by the caller). If the argument text couldn't be split // (e.g. it wasn't captured), skip silently. if (call.ArgumentExpressions.Count != expectedParams.Count) return; for (var i = 0; i < expectedParams.Count; i++) { var declared = NormalizeType(expectedParams[i]); if (declared is null) continue; // Object/List/unknown declaration accepts anything. var actual = InferLiteralType(call.ArgumentExpressions[i]); if (actual is null) continue; // Can't confidently infer the argument's type. if (!IsAssignable(actual.Value, declared.Value)) { errors.Add(ValidationEntry.Error(ValidationCategory.ParameterMismatch, $"Script '{callerName}' calls '{call.TargetName}' argument {i + 1} with type '{actual}' but parameter '{expectedParams[i]}' expects '{declared}'.", callerName)); } } } /// /// #20 — Return-type validation. When a call result is assigned directly /// into a typed local declaration (int x = CallScript(...), /// bool b = await CallShared(...)), compares the LHS declared type /// against the target's declared return type and flags clear mismatches. /// /// Conservatism (false-positive avoidance) — flagged only when ALL hold: /// /// The call result is captured by a typed local whose type is a known /// primitive (so var, object, dynamic, and untyped /// reuse are never flagged). /// The call is the WHOLE initializer (optionally preceded by /// await). If the result feeds an expression / method chain /// (e.g. (int)(await CallScript(...)), CallScript(...).X) /// the assigned-type is not captured and nothing is flagged. /// The target declares a known-primitive return type. Missing/Object/ /// List/unknown returns are never flagged. /// Integer⇄Float is compatible (numeric widening) — never flagged. /// /// private static void ValidateCallReturnType( string callerName, CallTarget call, Dictionary returnMap, List errors) { if (call.AssignedToType is null) return; // Result not captured by a typed local (var/untyped/unused). var expected = NormalizeType(call.AssignedToType); if (expected is null) return; // LHS isn't a known primitive — don't guess. if (!returnMap.TryGetValue(call.TargetName, out var returnDef)) return; var actual = NormalizeType(ParseReturnDefinitionType(returnDef)); if (actual is null) return; // Target's return type unknown/non-primitive. if (!IsAssignable(actual.Value, expected.Value)) { errors.Add(ValidationEntry.Error(ValidationCategory.ReturnTypeMismatch, $"Script '{callerName}' assigns the '{actual}' return value of '{call.TargetName}' to a '{expected}' variable.", callerName)); } } private static Dictionary> BuildParameterMap(IReadOnlyList scripts) { var result = new Dictionary>(StringComparer.Ordinal); foreach (var script in scripts) { // Per-parameter declared TYPE in declared order (raw type strings). // One entry per parameter, so the existing count check is preserved // while #21 also has the types it needs for positional matching. var parameters = ParseParameterTypes(script.ParameterDefinitions); result[script.CanonicalName] = parameters; } return result; } /// /// Parses a parameter definitions JSON string (JSON Schema or legacy flat /// array) and returns the declared parameter TYPE for each parameter, in /// declared order. Names are not needed for positional call validation; the /// returned count equals the parameter count (preserving the count check). /// /// JSON Schema or legacy flat-array string; null/empty returns an empty list. /// The per-parameter raw type strings (e.g. "Int32", "string", "List"). internal static List ParseParameterTypes(string? parameterDefinitionsJson) { if (string.IsNullOrWhiteSpace(parameterDefinitionsJson)) return []; try { using var doc = JsonDocument.Parse(parameterDefinitionsJson); // JSON Schema: { type:"object", properties:{ name:{ type:"integer" }, ... } } if (doc.RootElement.ValueKind == JsonValueKind.Object) { if (doc.RootElement.TryGetProperty("properties", out var props) && props.ValueKind == JsonValueKind.Object) { return props.EnumerateObject() .Select(p => p.Value.ValueKind == JsonValueKind.Object && p.Value.TryGetProperty("type", out var t) && t.ValueKind == JsonValueKind.String ? t.GetString() ?? "unknown" : "unknown") .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 the declared return type from a ReturnDefinition JSON string /// (JSON Schema {type:"..."} or legacy {type:"..."}). Returns /// null when absent or unparseable. /// /// JSON return definition; null/empty returns null. /// The raw return type string (e.g. "boolean", "Int32"), or null. internal static string? ParseReturnDefinitionType(string? returnDefinitionJson) { if (string.IsNullOrWhiteSpace(returnDefinitionJson)) return null; try { using var doc = JsonDocument.Parse(returnDefinitionJson); if (doc.RootElement.ValueKind == JsonValueKind.Object && doc.RootElement.TryGetProperty("type", out var t) && t.ValueKind == JsonValueKind.String) { return t.GetString(); } } catch (JsonException) { } return null; } private static Dictionary BuildReturnMap(IReadOnlyList scripts) { var result = new Dictionary(StringComparer.Ordinal); foreach (var script in scripts) { result[script.CanonicalName] = script.ReturnDefinition; } return result; } /// /// Extracts call targets from script code by simple pattern matching. /// Looks for CallScript("name", ...) and CallShared("name", ...) patterns. /// /// The script source code to scan. /// The list of call targets found (both CallScript and CallShared invocations). 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) { // First argument is the script name; the rest are the call's // positional arguments. var args = SplitCallArguments(code, argsStart); var argExpressions = args.Count > 1 ? args.GetRange(1, args.Count - 1) : new List(); results.Add(new CallTarget { TargetName = target, IsShared = isShared, ArgumentCount = argExpressions.Count, ArgumentExpressions = argExpressions, // #20: the declared type the result is assigned into, if the // call is the whole initializer of a typed local declaration. AssignedToType = ExtractAssignedToType(code, idx) }); } pos = argsStart; } } /// /// Splits a call's argument list (starting just after the opening paren) /// into top-level argument expressions, trimmed. Tracks parenthesis, brace, /// and bracket nesting plus string/char literals so object initializers, /// nested calls, collection expressions, and commas inside literals don't /// produce spurious splits. Element 0 is the script-name argument. /// private static List SplitCallArguments(string code, int startPos) { var args = new List(); var depthParen = 1; // we start inside the call's own '(' var depthBraceBracket = 0; var pos = startPos; var argStart = startPos; while (pos < code.Length) { var c = code[pos]; switch (c) { case '(': depthParen++; break; case ')': depthParen--; if (depthParen == 0) { AddArg(code, argStart, pos, args); return args; } break; case '{': case '[': depthBraceBracket++; break; case '}': case ']': if (depthBraceBracket > 0) depthBraceBracket--; break; case ',' when depthParen == 1 && depthBraceBracket == 0: AddArg(code, argStart, pos, args); argStart = pos + 1; break; case '"': case '\'': // Skip the literal body so its delimiters/commas are ignored. pos++; while (pos < code.Length && code[pos] != c) { if (code[pos] == '\\') pos++; // skip escaped char pos++; } break; case '/': // Skip C# line and block comments so commas inside them are ignored. // A `/` inside a string literal is already consumed above, so we only // reach here for real `/` tokens in code. if (pos + 1 < code.Length) { if (code[pos + 1] == '/') { // Line comment: skip to end-of-line. pos += 2; while (pos < code.Length && code[pos] != '\n') pos++; } else if (code[pos + 1] == '*') { // Block comment: skip to closing `*/`. pos += 2; while (pos + 1 < code.Length && !(code[pos] == '*' && code[pos + 1] == '/')) pos++; if (pos + 1 < code.Length) pos++; // step over the `/` } } break; } pos++; } // Unterminated call (shouldn't happen for compilable code) — best effort. AddArg(code, argStart, code.Length, args); return args; static void AddArg(string code, int start, int end, List acc) { var text = code[start..end].Trim(); // Only the trailing empty slice after a lone name (e.g. "foo",) is // dropped; an empty arg list ("foo") still yields just the name. if (text.Length > 0 || acc.Count == 0) acc.Add(text); } } /// /// #20 inference — looks backwards from the call's start index for a typed /// local declaration whose initializer is exactly this call (optionally /// preceded by await). The call may be qualified by a simple receiver /// (Instance., Scripts., Parent., /// Children["x"].) which is skipped. Returns the declared LHS type /// token, or null when the result isn't captured by a simple typed local /// (e.g. var, no assignment, reassignment to an existing variable, or /// the call is part of a larger expression such as a cast or longer /// member-access chain). /// private static string? ExtractAssignedToType(string code, int callIndex) { // Walk back over a simple dotted receiver immediately before the call — // e.g. the "Instance." / "Scripts." / "Children[\"x\"]." prefix on a // qualified call. Only identifier chars, '.', and bracketed indexers // (with string/identifier contents) are skipped; anything else (a ')', // an operator, another call's '(') means the call is embedded in a // larger expression and we must not infer. var receiverStart = SkipReceiverBackwards(code, callIndex); // Walk back over whitespace immediately before the receiver/call. var i = receiverStart - 1; while (i >= 0 && char.IsWhiteSpace(code[i])) i--; if (i < 0) return null; // The call must be the entire RHS: the char before it (after optional // 'await') must be '='. Anything else (')', '.', '(', operators) means // the result is consumed by a larger expression — don't infer. var beforeCall = code[..(i + 1)]; // Strip a trailing 'await' so "= await CallScript(...)" is handled. var awaitTrimmed = beforeCall.TrimEnd(); if (awaitTrimmed.EndsWith("await", StringComparison.Ordinal) && (awaitTrimmed.Length == 5 || !IsIdentifierChar(awaitTrimmed[^6]))) { beforeCall = awaitTrimmed[..^5]; } beforeCall = beforeCall.TrimEnd(); if (!beforeCall.EndsWith('=')) return null; // Exclude '==', '<=', '>=', '!=' etc. — comparisons, not assignment. if (beforeCall.Length >= 2) { var prev = beforeCall[^2]; if (prev is '=' or '!' or '<' or '>' or '+' or '-' or '*' or '/' or '%' or '&' or '|' or '^') return null; } // Now parse the " " declaration that precedes the '='. var decl = beforeCall[..^1].TrimEnd(); // Identifier (the variable name). var end = decl.Length; var nameEnd = end; while (nameEnd > 0 && IsIdentifierChar(decl[nameEnd - 1])) nameEnd--; if (nameEnd == end) return null; // no identifier var nameStart = nameEnd; // Whitespace between type and name. var ws = nameStart; while (ws > 0 && char.IsWhiteSpace(decl[ws - 1])) ws--; if (ws == nameStart) return null; // need separating whitespace → "type name" // The type token (single identifier/keyword — no generics/arrays here; // those normalize to unknown anyway and stay unflagged). var typeEnd = ws; var typeStart = typeEnd; while (typeStart > 0 && IsIdentifierChar(decl[typeStart - 1])) typeStart--; if (typeStart == typeEnd) return null; // Guard against picking up a keyword that isn't a type in this position // (e.g. "return x = ..."). A real declaration's type token is preceded // by a statement boundary or open brace, not by another identifier. if (typeStart > 0) { var b = typeStart - 1; while (b >= 0 && char.IsWhiteSpace(decl[b])) b--; if (b >= 0 && IsIdentifierChar(decl[b])) return null; // preceded by another word → not a clean declaration } return decl[typeStart..typeEnd]; } private static bool IsIdentifierChar(char c) => char.IsLetterOrDigit(c) || c == '_'; /// /// Given the index of a CallScript/CallShared token, walks /// backwards over a leading receiver expression composed only of identifier /// chars, '.', and bracketed indexers (["x"]), and returns the index /// where that receiver begins. If there is no '.' immediately before the /// token (an unqualified call) the original index is returned unchanged. /// Stops at the first character that can't be part of such a simple /// receiver, so casts/parenthesised/chained-method receivers aren't /// mistaken for a clean assignment target. /// private static int SkipReceiverBackwards(string code, int callIndex) { var i = callIndex - 1; // Optional whitespace then must be a '.' for there to be a receiver. while (i >= 0 && char.IsWhiteSpace(code[i])) i--; if (i < 0 || code[i] != '.') return callIndex; var start = callIndex; while (i >= 0) { var c = code[i]; if (c == '.' || IsIdentifierChar(c) || char.IsWhiteSpace(c)) { start = i; i--; continue; } if (c == ']') { // Skip a single (non-nested) indexer "[ ... ]" with string or // identifier contents — e.g. Children["pump"]. var j = i - 1; while (j >= 0 && code[j] != '[' && code[j] != '(' && code[j] != ')') j--; if (j < 0 || code[j] != '[') return start; start = j; i = j - 1; continue; } break; } return start; } // ── Script-level type vocabulary (#20/#21) ────────────────────────────── // // The template scripting "type system" exposed in ParameterDefinitions / // ReturnDefinition is a small set: String, Integer, Float, Boolean, plus // Object / List (and arbitrary unrecognised names). Only the four scalar // primitives below are matched; everything else maps to null ("unknown"), // which the validators treat as "accept anything / don't flag". private enum ScriptType { String, Integer, Float, Boolean } /// /// Maps a declared type token (JSON-Schema name, legacy name, or a C# type /// keyword used on a call-site LHS) onto a , or null /// when the type isn't one of the confidently-checkable primitives. /// private static ScriptType? NormalizeType(string? raw) { if (string.IsNullOrWhiteSpace(raw)) return null; return raw.Trim().ToLowerInvariant() switch { "string" or "datetime" => ScriptType.String, "integer" or "int" or "int32" or "int64" or "long" or "short" or "byte" => ScriptType.Integer, "float" or "double" or "decimal" or "number" or "single" => ScriptType.Float, "boolean" or "bool" => ScriptType.Boolean, // Object, List, array, var, dynamic, and anything else → unknown. _ => null, }; } /// /// Infers the of a call-site argument expression, /// but ONLY for unambiguous literals. Returns null for variables, member /// access, method/await chains, null, casts, parenthesised/compound /// expressions, and object/array/collection initializers — those can't be /// statically typed here and must never be flagged. /// private static ScriptType? InferLiteralType(string expr) { expr = expr.Trim(); if (expr.Length == 0) return null; // String / char literal — but only if the WHOLE expression is the // literal (so "a" + x or x + "b" stays unknown). if ((expr[0] == '"' || expr[0] == '\'') && IsWholeStringLiteral(expr)) return ScriptType.String; if (expr.StartsWith('@') && expr.Length > 1 && expr[1] == '"' && IsWholeStringLiteral(expr[1..])) return ScriptType.String; if (expr.StartsWith('$')) return null; // interpolated string — string-ish, but be conservative. if (expr is "true" or "false") return ScriptType.Boolean; // Numeric literal (optionally signed). Float if it has a '.', 'e'/'E' // exponent, or a float/double/decimal suffix; otherwise Integer. if (IsNumericLiteral(expr, out var isFloat)) return isFloat ? ScriptType.Float : ScriptType.Integer; return null; // Not a literal we can confidently classify. } private static bool IsWholeStringLiteral(string expr) { if (expr.Length < 2) return false; var quote = expr[0]; if (quote != '"' && quote != '\'') return false; var i = 1; while (i < expr.Length) { if (expr[i] == '\\') { i += 2; continue; } if (expr[i] == quote) return i == expr.Length - 1; // closing quote must be last char i++; } return false; } private static bool IsNumericLiteral(string expr, out bool isFloat) { isFloat = false; var i = 0; if (expr.Length == 0) return false; if (expr[0] == '+' || expr[0] == '-') i++; // A genuine numeric literal must start with a digit or a `.` followed by a // digit. Identifiers that start with `_` or a letter (e.g. `_2`, `count`) // are explicitly rejected here so they are inferred as Unknown, not Integer. if (i >= expr.Length) return false; var first = expr[i]; if (first == '.') { if (i + 1 >= expr.Length || !char.IsDigit(expr[i + 1])) return false; } else if (!char.IsDigit(first)) { return false; // starts with `_`, letter, or anything else → not a literal } var sawDigit = false; var sawDot = false; var sawExp = false; for (; i < expr.Length; i++) { var c = expr[i]; if (char.IsDigit(c)) { sawDigit = true; continue; } if (c == '_' && sawDigit) continue; // digit separator — only valid between digits if (c == '.' && !sawDot && !sawExp) { sawDot = true; isFloat = true; continue; } if ((c == 'e' || c == 'E') && !sawExp && sawDigit) { sawExp = true; isFloat = true; if (i + 1 < expr.Length && (expr[i + 1] == '+' || expr[i + 1] == '-')) i++; continue; } // Numeric suffix terminates the literal. if (i == expr.Length - 1 || (i == expr.Length - 2)) { var suffix = expr[i..].ToLowerInvariant(); switch (suffix) { case "f": case "d": case "m": isFloat = true; return sawDigit; case "l": case "u": case "ul": case "lu": return sawDigit; // integer suffixes } } return false; // any other char → not a plain numeric literal } return sawDigit; } /// /// Whether an argument/return of type is /// acceptable where is declared. Exact match, or /// Integer⇄Float numeric widening. All other cross-category pairings /// (String↔number, String↔Boolean, Boolean↔number) are mismatches. /// private static bool IsAssignable(ScriptType actual, ScriptType expected) { if (actual == expected) return true; // Numeric widening / narrowing between Integer and Float is tolerated — // the scripting runtime coerces these and flagging them is noisy. return (actual == ScriptType.Integer && expected == ScriptType.Float) || (actual == ScriptType.Float && expected == ScriptType.Integer); } 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]; } internal record CallTarget { /// Name of the script being called. public string TargetName { get; init; } = string.Empty; /// True when the call is to a shared script via CallShared. public bool IsShared { get; init; } /// Number of non-name arguments passed to the call. public int ArgumentCount { get; init; } /// The trimmed text of each non-name positional argument expression, in order. public IReadOnlyList ArgumentExpressions { get; init; } = []; /// /// The declared type token the call result is assigned into, when the /// call is the whole initializer of a typed local declaration; otherwise /// null (var/untyped/unused/expression-embedded). Used by #20. /// public string? AssignedToType { get; init; } } }