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()
{