#20 return-type: when a CallScript/CallShared result is assigned directly into a typed local declaration (optionally awaited, optionally via an Instance./ Scripts./Parent./Children["x"]. receiver), compare the LHS declared type against the target script's declared ReturnDefinition and flag clear cross-category mismatches (ReturnTypeMismatch). Previously BuildReturnMap was built but never read. #21 argument-type: positional call arguments are now split (paren/brace/bracket + string-literal aware) and each literal-inferable argument is checked against the target's declared parameter type (ParameterMismatch), not just the count. Conservative — only CLEAR primitive mismatches (String/Integer/Float/Boolean) are flagged; Integer<->Float widening is tolerated. Unknown/Object/List declarations, var/untyped/unused/expression-embedded assignments, and non-literal arguments (variables, member access, method/await chains, casts, object/array initializers, compound or concatenated expressions, interpolated strings) are never flagged. Inference limits documented in code. Adds 16 SemanticValidatorTests covering mismatch detection, correct-call pass, and the dynamic/unknown no-false-positive cases.
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #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:
|
||||
/// <list type="bullet">
|
||||
/// <item>Declared type must normalize to a known primitive (String, Integer,
|
||||
/// Float, Boolean). <c>Object</c>/<c>List</c>/unknown declarations accept
|
||||
/// anything — never flagged.</item>
|
||||
/// <item>Argument expression type must be inferable from a literal
|
||||
/// (string/char, integer, decimal, <c>true</c>/<c>false</c>). Variables,
|
||||
/// member access, method/await chains, <c>null</c>, casts, object/array
|
||||
/// initializers, and anything else infer to Unknown and are never flagged.</item>
|
||||
/// <item>Integer⇄Float is treated as compatible (numeric widening) — never
|
||||
/// flagged.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
private static void ValidateArgumentTypes(
|
||||
string callerName,
|
||||
CallTarget call,
|
||||
List<string> expectedParams,
|
||||
List<ValidationEntry> 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #20 — Return-type validation. When a call result is assigned directly
|
||||
/// into a typed local declaration (<c>int x = CallScript(...)</c>,
|
||||
/// <c>bool b = await CallShared(...)</c>), 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:
|
||||
/// <list type="bullet">
|
||||
/// <item>The call result is captured by a typed local whose type is a known
|
||||
/// primitive (so <c>var</c>, <c>object</c>, <c>dynamic</c>, and untyped
|
||||
/// reuse are never flagged).</item>
|
||||
/// <item>The call is the WHOLE initializer (optionally preceded by
|
||||
/// <c>await</c>). If the result feeds an expression / method chain
|
||||
/// (e.g. <c>(int)(await CallScript(...))</c>, <c>CallScript(...).X</c>)
|
||||
/// the assigned-type is not captured and nothing is flagged.</item>
|
||||
/// <item>The target declares a known-primitive return type. Missing/Object/
|
||||
/// List/unknown returns are never flagged.</item>
|
||||
/// <item>Integer⇄Float is compatible (numeric widening) — never flagged.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
private static void ValidateCallReturnType(
|
||||
string callerName,
|
||||
CallTarget call,
|
||||
Dictionary<string, string?> returnMap,
|
||||
List<ValidationEntry> 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<string, List<string>>(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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
/// <param name="parameterDefinitionsJson">JSON Schema or legacy flat-array string; null/empty returns an empty list.</param>
|
||||
/// <returns>The per-parameter raw type strings (e.g. "Int32", "string", "List").</returns>
|
||||
internal static List<string> 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 [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts the declared return type from a ReturnDefinition JSON string
|
||||
/// (JSON Schema <c>{type:"..."}</c> or legacy <c>{type:"..."}</c>). Returns
|
||||
/// null when absent or unparseable.
|
||||
/// </summary>
|
||||
/// <param name="returnDefinitionJson">JSON return definition; null/empty returns null.</param>
|
||||
/// <returns>The raw return type string (e.g. "boolean", "Int32"), or null.</returns>
|
||||
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<string, string?> BuildReturnMap(IReadOnlyList<ResolvedScript> scripts)
|
||||
{
|
||||
var result = new Dictionary<string, string?>(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<string>();
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
private static List<string> SplitCallArguments(string code, int startPos)
|
||||
{
|
||||
var args = new List<string>();
|
||||
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<string> 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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #20 inference — looks backwards from the call's start index for a typed
|
||||
/// local declaration whose initializer is exactly this call (optionally
|
||||
/// preceded by <c>await</c>). The call may be qualified by a simple receiver
|
||||
/// (<c>Instance.</c>, <c>Scripts.</c>, <c>Parent.</c>,
|
||||
/// <c>Children["x"].</c>) which is skipped. Returns the declared LHS type
|
||||
/// token, or null when the result isn't captured by a simple typed local
|
||||
/// (e.g. <c>var</c>, 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).
|
||||
/// </summary>
|
||||
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 "<type> <name>" 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 == '_';
|
||||
|
||||
/// <summary>
|
||||
/// Given the index of a <c>CallScript</c>/<c>CallShared</c> token, walks
|
||||
/// backwards over a leading receiver expression composed only of identifier
|
||||
/// chars, '.', and bracketed indexers (<c>["x"]</c>), 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.
|
||||
/// </summary>
|
||||
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 }
|
||||
|
||||
/// <summary>
|
||||
/// Maps a declared type token (JSON-Schema name, legacy name, or a C# type
|
||||
/// keyword used on a call-site LHS) onto a <see cref="ScriptType"/>, or null
|
||||
/// when the type isn't one of the confidently-checkable primitives.
|
||||
/// </summary>
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Infers the <see cref="ScriptType"/> of a call-site argument expression,
|
||||
/// but ONLY for unambiguous literals. Returns null for variables, member
|
||||
/// access, method/await chains, <c>null</c>, casts, parenthesised/compound
|
||||
/// expressions, and object/array/collection initializers — those can't be
|
||||
/// statically typed here and must never be flagged.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether an argument/return of <paramref name="actual"/> type is
|
||||
/// acceptable where <paramref name="expected"/> is declared. Exact match, or
|
||||
/// Integer⇄Float numeric widening. All other cross-category pairings
|
||||
/// (String↔number, String↔Boolean, Boolean↔number) are mismatches.
|
||||
/// </summary>
|
||||
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
|
||||
{
|
||||
/// <summary>Name of the script being called.</summary>
|
||||
@@ -432,5 +918,13 @@ public class SemanticValidator
|
||||
public bool IsShared { get; init; }
|
||||
/// <summary>Number of non-name arguments passed to the call.</summary>
|
||||
public int ArgumentCount { get; init; }
|
||||
/// <summary>The trimmed text of each non-name positional argument expression, in order.</summary>
|
||||
public IReadOnlyList<string> ArgumentExpressions { get; init; } = [];
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public string? AssignedToType { get; init; }
|
||||
}
|
||||
}
|
||||
|
||||
+527
@@ -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<ResolvedScript>
|
||||
{
|
||||
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()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user