diff --git a/src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Validation/SemanticValidator.cs b/src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Validation/SemanticValidator.cs index 152e0a15..ced53abe 100644 --- a/src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Validation/SemanticValidator.cs +++ b/src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Validation/SemanticValidator.cs @@ -80,6 +80,7 @@ public class SemanticValidator else { ValidateCallParameters(script.CanonicalName, call, sharedParamMap, errors); + ValidateCallReturnType(script.CanonicalName, call, sharedReturnMap, errors); } } else @@ -94,6 +95,7 @@ public class SemanticValidator 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)) @@ -262,6 +264,109 @@ public class SemanticValidator 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)); } } @@ -270,12 +375,90 @@ public class SemanticValidator var result = new Dictionary>(StringComparer.Ordinal); foreach (var script in scripts) { - var parameters = ParseParameterDefinitions(script.ParameterDefinitions); + // 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); @@ -353,12 +536,22 @@ public class SemanticValidator var target = ExtractStringArgument(code, argsStart); if (target != null) { - var argCount = CountArguments(code, argsStart); + // 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 = Math.Max(0, argCount - 1) // First arg is the name, rest are parameters + 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) }); } @@ -366,6 +559,336 @@ public class SemanticValidator } } + /// + /// 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; + } + 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++; + + 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 == '_') continue; // digit separator + 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 @@ -387,43 +910,6 @@ public class SemanticValidator 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 { /// Name of the script being called. @@ -432,5 +918,13 @@ public class SemanticValidator 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; } } } diff --git a/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Validation/SemanticValidatorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Validation/SemanticValidatorTests.cs index 0c82b9c3..5c061091 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Validation/SemanticValidatorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Validation/SemanticValidatorTests.cs @@ -151,6 +151,533 @@ public class SemanticValidatorTests Assert.Contains(result.Errors, e => e.Category == ValidationCategory.ParameterMismatch); } + // ── #21 Argument-type validation ──────────────────────────────────────── + + [Fact] + public void Validate_ArgumentTypeMismatch_StringForInteger_ReturnsError() + { + // Target expects (Integer a); caller passes a string literal. + var config = new FlattenedConfiguration + { + InstanceUniqueName = "Instance1", + Scripts = + [ + new ResolvedScript + { + CanonicalName = "Target", + Code = "var x = 1;", + ParameterDefinitions = "[{\"name\":\"a\",\"type\":\"Int32\"}]" + }, + new ResolvedScript + { + CanonicalName = "Caller", + Code = "CallScript(\"Target\", \"hello\");" + } + ] + }; + + var result = _sut.Validate(config); + Assert.Contains(result.Errors, e => + e.Category == ValidationCategory.ParameterMismatch && + e.Message.Contains("type", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public void Validate_ArgumentTypeMismatch_NumberForString_ReturnsError() + { + // Target expects (String a); caller passes an integer literal. + var config = new FlattenedConfiguration + { + InstanceUniqueName = "Instance1", + Scripts = + [ + new ResolvedScript + { + CanonicalName = "Target", + Code = "var x = 1;", + ParameterDefinitions = "[{\"name\":\"a\",\"type\":\"String\"}]" + }, + new ResolvedScript + { + CanonicalName = "Caller", + Code = "CallScript(\"Target\", 42);" + } + ] + }; + + var result = _sut.Validate(config); + Assert.Contains(result.Errors, e => + e.Category == ValidationCategory.ParameterMismatch && + e.Message.Contains("type", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public void Validate_ArgumentTypeMismatch_BooleanForInteger_ReturnsError() + { + var config = new FlattenedConfiguration + { + InstanceUniqueName = "Instance1", + Scripts = + [ + new ResolvedScript + { + CanonicalName = "Target", + Code = "var x = 1;", + ParameterDefinitions = "[{\"name\":\"a\",\"type\":\"Integer\"}]" + }, + new ResolvedScript + { + CanonicalName = "Caller", + Code = "CallScript(\"Target\", true);" + } + ] + }; + + var result = _sut.Validate(config); + Assert.Contains(result.Errors, e => + e.Category == ValidationCategory.ParameterMismatch && + e.Message.Contains("type", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public void Validate_ArgumentTypeMatch_CorrectLiterals_NoError() + { + // (Integer a, String b, Boolean c) called with matching literals. + var config = new FlattenedConfiguration + { + InstanceUniqueName = "Instance1", + Scripts = + [ + new ResolvedScript + { + CanonicalName = "Target", + Code = "var x = 1;", + ParameterDefinitions = + "[{\"name\":\"a\",\"type\":\"Integer\"},{\"name\":\"b\",\"type\":\"String\"},{\"name\":\"c\",\"type\":\"Boolean\"}]" + }, + new ResolvedScript + { + CanonicalName = "Caller", + Code = "CallScript(\"Target\", 42, \"hi\", true);" + } + ] + }; + + var result = _sut.Validate(config); + Assert.DoesNotContain(result.Errors, e => e.Category == ValidationCategory.ParameterMismatch); + } + + [Fact] + public void Validate_ArgumentType_IntegerLiteralForFloat_NoError() + { + // Numeric widening: an integer literal is acceptable where a Float is declared. + var config = new FlattenedConfiguration + { + InstanceUniqueName = "Instance1", + Scripts = + [ + new ResolvedScript + { + CanonicalName = "Target", + Code = "var x = 1;", + ParameterDefinitions = "[{\"name\":\"a\",\"type\":\"Float\"}]" + }, + new ResolvedScript + { + CanonicalName = "Caller", + Code = "CallScript(\"Target\", 5);" + } + ] + }; + + var result = _sut.Validate(config); + Assert.DoesNotContain(result.Errors, e => e.Category == ValidationCategory.ParameterMismatch); + } + + [Fact] + public void Validate_ArgumentType_UnknownExpression_NoFalsePositive() + { + // The argument is a variable/expression whose type can't be statically + // inferred — must NOT be flagged even though it could be anything. + var config = new FlattenedConfiguration + { + InstanceUniqueName = "Instance1", + Scripts = + [ + new ResolvedScript + { + CanonicalName = "Target", + Code = "var x = 1;", + ParameterDefinitions = "[{\"name\":\"a\",\"type\":\"Integer\"}]" + }, + new ResolvedScript + { + CanonicalName = "Caller", + Code = "var v = Attributes[\"Temp\"].Value; CallScript(\"Target\", v);" + } + ] + }; + + var result = _sut.Validate(config); + Assert.DoesNotContain(result.Errors, e => e.Category == ValidationCategory.ParameterMismatch); + } + + [Fact] + public void Validate_ArgumentType_ObjectInitializerArgument_NoFalsePositive() + { + // Real-world call shape: a single anonymous-object argument. Object + // initializers can't be mapped to positional primitive params — skip. + var config = new FlattenedConfiguration + { + InstanceUniqueName = "Instance1", + Scripts = + [ + new ResolvedScript + { + CanonicalName = "Target", + Code = "var x = 1;", + ParameterDefinitions = "[{\"name\":\"a\",\"type\":\"Integer\"}]" + }, + new ResolvedScript + { + CanonicalName = "Caller", + Code = "CallScript(\"Target\", new { a = 5 });" + } + ] + }; + + var result = _sut.Validate(config); + Assert.DoesNotContain(result.Errors, e => e.Category == ValidationCategory.ParameterMismatch); + } + + [Fact] + public void Validate_ArgumentType_UntypedParameter_NoFalsePositive() + { + // Target declares an Object parameter — anything is assignable, no flag. + var config = new FlattenedConfiguration + { + InstanceUniqueName = "Instance1", + Scripts = + [ + new ResolvedScript + { + CanonicalName = "Target", + Code = "var x = 1;", + ParameterDefinitions = "[{\"name\":\"a\",\"type\":\"Object\"}]" + }, + new ResolvedScript + { + CanonicalName = "Caller", + Code = "CallScript(\"Target\", \"anything\");" + } + ] + }; + + var result = _sut.Validate(config); + Assert.DoesNotContain(result.Errors, e => e.Category == ValidationCategory.ParameterMismatch); + } + + [Fact] + public void Validate_ArgumentType_CompoundExpressionStartingWithLiteral_NoFalsePositive() + { + // `42 + offset` starts with an int literal but is a compound expression + // of unknown type — must NOT be classified or flagged. + var config = new FlattenedConfiguration + { + InstanceUniqueName = "Instance1", + Scripts = + [ + new ResolvedScript + { + CanonicalName = "Target", + Code = "var x = 1;", + ParameterDefinitions = "[{\"name\":\"a\",\"type\":\"String\"}]" + }, + new ResolvedScript + { + CanonicalName = "Caller", + Code = "CallScript(\"Target\", 42 + offset);" + } + ] + }; + + var result = _sut.Validate(config); + Assert.DoesNotContain(result.Errors, e => e.Category == ValidationCategory.ParameterMismatch); + } + + [Fact] + public void Validate_ArgumentType_ConcatenatedStringExpression_NoFalsePositive() + { + // `"a" + x` starts with a string literal but is a concatenation of + // unknown overall type — be conservative, don't flag against Integer. + var config = new FlattenedConfiguration + { + InstanceUniqueName = "Instance1", + Scripts = + [ + new ResolvedScript + { + CanonicalName = "Target", + Code = "var x = 1;", + ParameterDefinitions = "[{\"name\":\"a\",\"type\":\"Integer\"}]" + }, + new ResolvedScript + { + CanonicalName = "Caller", + Code = "CallScript(\"Target\", \"a\" + x);" + } + ] + }; + + var result = _sut.Validate(config); + Assert.DoesNotContain(result.Errors, e => e.Category == ValidationCategory.ParameterMismatch); + } + + // ── #20 Return-type validation ────────────────────────────────────────── + + [Fact] + public void Validate_ReturnTypeMismatch_BooleanResultIntoInt_ReturnsError() + { + // Target returns Boolean; caller assigns it into a typed `int` local. + var config = new FlattenedConfiguration + { + InstanceUniqueName = "Instance1", + Scripts = + [ + new ResolvedScript + { + CanonicalName = "Target", + Code = "return true;", + ReturnDefinition = "{\"type\":\"boolean\"}" + }, + new ResolvedScript + { + CanonicalName = "Caller", + Code = "int x = CallScript(\"Target\");" + } + ] + }; + + var result = _sut.Validate(config); + Assert.Contains(result.Errors, e => e.Category == ValidationCategory.ReturnTypeMismatch); + } + + [Fact] + public void Validate_ReturnTypeMismatch_StringResultIntoBool_ReturnsError() + { + var config = new FlattenedConfiguration + { + InstanceUniqueName = "Instance1", + Scripts = + [ + new ResolvedScript + { + CanonicalName = "Target", + Code = "return \"x\";", + ReturnDefinition = "{\"type\":\"string\"}" + }, + new ResolvedScript + { + CanonicalName = "Caller", + Code = "bool b = await CallScript(\"Target\");" + } + ] + }; + + var result = _sut.Validate(config); + Assert.Contains(result.Errors, e => e.Category == ValidationCategory.ReturnTypeMismatch); + } + + [Fact] + public void Validate_ReturnTypeMatch_CompatibleAssignment_NoError() + { + // Target returns Integer; caller assigns into an `int` local — compatible. + var config = new FlattenedConfiguration + { + InstanceUniqueName = "Instance1", + Scripts = + [ + new ResolvedScript + { + CanonicalName = "Target", + Code = "return 1;", + ReturnDefinition = "{\"type\":\"integer\"}" + }, + new ResolvedScript + { + CanonicalName = "Caller", + Code = "int x = CallScript(\"Target\");" + } + ] + }; + + var result = _sut.Validate(config); + Assert.DoesNotContain(result.Errors, e => e.Category == ValidationCategory.ReturnTypeMismatch); + } + + [Fact] + public void Validate_ReturnType_VarAssignment_NoFalsePositive() + { + // `var` LHS — caller's expected type can't be inferred, so no flag. + var config = new FlattenedConfiguration + { + InstanceUniqueName = "Instance1", + Scripts = + [ + new ResolvedScript + { + CanonicalName = "Target", + Code = "return \"x\";", + ReturnDefinition = "{\"type\":\"string\"}" + }, + new ResolvedScript + { + CanonicalName = "Caller", + Code = "var x = CallScript(\"Target\");" + } + ] + }; + + var result = _sut.Validate(config); + Assert.DoesNotContain(result.Errors, e => e.Category == ValidationCategory.ReturnTypeMismatch); + } + + [Fact] + public void Validate_ReturnType_UnusedResult_NoFalsePositive() + { + // Result isn't assigned anywhere — nothing to check. + var config = new FlattenedConfiguration + { + InstanceUniqueName = "Instance1", + Scripts = + [ + new ResolvedScript + { + CanonicalName = "Target", + Code = "return \"x\";", + ReturnDefinition = "{\"type\":\"string\"}" + }, + new ResolvedScript + { + CanonicalName = "Caller", + Code = "CallScript(\"Target\");" + } + ] + }; + + var result = _sut.Validate(config); + Assert.DoesNotContain(result.Errors, e => e.Category == ValidationCategory.ReturnTypeMismatch); + } + + [Fact] + public void Validate_ReturnType_UndeclaredReturn_NoFalsePositive() + { + // Target has no ReturnDefinition — can't compare, so no flag. + var config = new FlattenedConfiguration + { + InstanceUniqueName = "Instance1", + Scripts = + [ + new ResolvedScript { CanonicalName = "Target", Code = "return 1;" }, + new ResolvedScript + { + CanonicalName = "Caller", + Code = "string s = CallScript(\"Target\");" + } + ] + }; + + var result = _sut.Validate(config); + Assert.DoesNotContain(result.Errors, e => e.Category == ValidationCategory.ReturnTypeMismatch); + } + + [Fact] + public void Validate_ReturnTypeMismatch_QualifiedInstanceCall_ReturnsError() + { + // Real-code form: `Instance.CallScript(...)`. The receiver prefix must + // be skipped so #20 still sees the typed-local assignment. + var config = new FlattenedConfiguration + { + InstanceUniqueName = "Instance1", + Scripts = + [ + new ResolvedScript + { + CanonicalName = "Target", + Code = "return \"x\";", + ReturnDefinition = "{\"type\":\"string\"}" + }, + new ResolvedScript + { + CanonicalName = "Caller", + Code = "bool b = await Instance.CallScript(\"Target\");" + } + ] + }; + + var result = _sut.Validate(config); + Assert.Contains(result.Errors, e => e.Category == ValidationCategory.ReturnTypeMismatch); + } + + [Fact] + public void Validate_ReturnTypeMismatch_QualifiedSharedCall_ReturnsError() + { + // Real-code form: `Scripts.CallShared(...)`. + var config = new FlattenedConfiguration + { + InstanceUniqueName = "Instance1", + Scripts = + [ + new ResolvedScript + { + CanonicalName = "Caller", + Code = "int n = Scripts.CallShared(\"Util\");" + } + ] + }; + + var shared = new List + { + new() + { + CanonicalName = "Util", + Code = "return true;", + ReturnDefinition = "{\"type\":\"boolean\"}" + } + }; + + var result = _sut.Validate(config, shared); + Assert.Contains(result.Errors, e => e.Category == ValidationCategory.ReturnTypeMismatch); + } + + [Fact] + public void Validate_ReturnType_CastExpression_NoFalsePositive() + { + // The result feeds a cast expression — not a clean typed-local + // assignment, so the assigned type can't be inferred. No flag. + var config = new FlattenedConfiguration + { + InstanceUniqueName = "Instance1", + Scripts = + [ + new ResolvedScript + { + CanonicalName = "Target", + Code = "return \"x\";", + ReturnDefinition = "{\"type\":\"string\"}" + }, + new ResolvedScript + { + CanonicalName = "Caller", + Code = "int x = (int)CallScript(\"Target\");" + } + ] + }; + + var result = _sut.Validate(config); + Assert.DoesNotContain(result.Errors, e => e.Category == ValidationCategory.ReturnTypeMismatch); + } + [Fact] public void Validate_RangeViolationOnNonNumeric_ReturnsError() {