- Core.Scripting-005: DependencyExtractor.HandleTagCall now recognises raw-string literal paths by checking the StringLiteralExpression node kind instead of the legacy StringLiteralToken kind. - Core.Scripting-006: scope CompiledScriptCache failed-compile eviction with TryRemove(KeyValuePair) so a racing retry entry is not evicted. - Core.Scripting-008: document the per-publish assembly accretion as an accepted limitation in docs/VirtualTags.md. - Core.Scripting-009: enumerate the authoritative deny-list (namespace prefixes + type-granular denies) in the Phase 7 decision-#6 entry to match ForbiddenTypeAnalyzer. - Core.Scripting-011: pin ScriptSandbox.Build, ScriptContext.Deadband boundary semantics, and end-to-end factory + companion-sink integration. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
153 lines
7.1 KiB
C#
153 lines
7.1 KiB
C#
using Microsoft.CodeAnalysis;
|
|
using Microsoft.CodeAnalysis.CSharp;
|
|
using Microsoft.CodeAnalysis.CSharp.Syntax;
|
|
using Microsoft.CodeAnalysis.Text;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
|
|
|
/// <summary>
|
|
/// Parses a script's source text + extracts every <c>ctx.GetTag("literal")</c> and
|
|
/// <c>ctx.SetVirtualTag("literal", ...)</c> call. Outputs the static dependency set
|
|
/// the virtual-tag engine uses to build its change-trigger subscription graph (Phase
|
|
/// 7 plan decision #7 — AST inference, operator doesn't maintain a separate list).
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// The tag-path argument MUST be a literal string expression. Variables,
|
|
/// concatenation, interpolation, and method-returned strings are rejected because
|
|
/// the extractor can't statically know what tag they'll resolve to at evaluation
|
|
/// time — the dependency graph needs to know every possible input up front.
|
|
/// Rejections carry the exact source span so the Admin UI can point at the offending
|
|
/// token.
|
|
/// </para>
|
|
/// <para>
|
|
/// Matching is by spelling: the extractor looks for member-access invocations
|
|
/// whose receiver identifier is literally <c>ctx</c> and whose method name is
|
|
/// <c>GetTag</c> or <c>SetVirtualTag</c>. A deliberately misspelled method call
|
|
/// (<c>ctx.GetTagz</c>) is not picked up but will also fail to compile against
|
|
/// <see cref="ScriptContext"/>, so there is no way to smuggle a dependency past the
|
|
/// extractor while still having a working script. Calls with the same method name on
|
|
/// a different receiver (<c>other.GetTag("X")</c>) are explicitly ignored so that
|
|
/// scripts defining local helper types with matching names do not produce spurious
|
|
/// dependencies. (Core.Scripting-004.)
|
|
/// </para>
|
|
/// </remarks>
|
|
public static class DependencyExtractor
|
|
{
|
|
/// <summary>
|
|
/// Parse <paramref name="scriptSource"/> + return the inferred read + write tag
|
|
/// paths, or a list of rejection messages if non-literal paths were used.
|
|
/// </summary>
|
|
public static DependencyExtractionResult Extract(string scriptSource)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(scriptSource))
|
|
return new DependencyExtractionResult(
|
|
Reads: new HashSet<string>(StringComparer.Ordinal),
|
|
Writes: new HashSet<string>(StringComparer.Ordinal),
|
|
Rejections: []);
|
|
|
|
var tree = CSharpSyntaxTree.ParseText(scriptSource, options:
|
|
new CSharpParseOptions(kind: SourceCodeKind.Script));
|
|
var root = tree.GetRoot();
|
|
|
|
var walker = new Walker();
|
|
walker.Visit(root);
|
|
|
|
return new DependencyExtractionResult(
|
|
Reads: walker.Reads,
|
|
Writes: walker.Writes,
|
|
Rejections: walker.Rejections);
|
|
}
|
|
|
|
private sealed class Walker : CSharpSyntaxWalker
|
|
{
|
|
private readonly HashSet<string> _reads = new(StringComparer.Ordinal);
|
|
private readonly HashSet<string> _writes = new(StringComparer.Ordinal);
|
|
private readonly List<DependencyRejection> _rejections = [];
|
|
|
|
public IReadOnlySet<string> Reads => _reads;
|
|
public IReadOnlySet<string> Writes => _writes;
|
|
public IReadOnlyList<DependencyRejection> Rejections => _rejections;
|
|
|
|
public override void VisitInvocationExpression(InvocationExpressionSyntax node)
|
|
{
|
|
// Only interested in ctx.GetTag(...) / ctx.SetVirtualTag(...) — member-access
|
|
// form where the receiver is the identifier "ctx" (the ScriptGlobals<T>.ctx
|
|
// field). Calls with the same method name on a different receiver (e.g.
|
|
// someHelper.GetTag("X")) are ignored — not picking them up avoids spurious
|
|
// dependencies when scripts define local types with matching method names.
|
|
// (Core.Scripting-004.)
|
|
if (node.Expression is MemberAccessExpressionSyntax member
|
|
&& member.Expression is IdentifierNameSyntax receiver
|
|
&& receiver.Identifier.ValueText == "ctx")
|
|
{
|
|
var methodName = member.Name.Identifier.ValueText;
|
|
if (methodName is nameof(ScriptContext.GetTag) or nameof(ScriptContext.SetVirtualTag))
|
|
{
|
|
HandleTagCall(node, methodName);
|
|
}
|
|
}
|
|
base.VisitInvocationExpression(node);
|
|
}
|
|
|
|
private void HandleTagCall(InvocationExpressionSyntax node, string methodName)
|
|
{
|
|
var args = node.ArgumentList.Arguments;
|
|
if (args.Count == 0)
|
|
{
|
|
_rejections.Add(new DependencyRejection(
|
|
Span: node.Span,
|
|
Message: $"Call to ctx.{methodName} has no arguments. " +
|
|
"The tag path must be the first argument."));
|
|
return;
|
|
}
|
|
|
|
var pathArg = args[0].Expression;
|
|
// Accept any string-literal expression, including raw-string forms which
|
|
// tokenize as SingleLineRawStringLiteralToken / MultiLineRawStringLiteralToken
|
|
// rather than StringLiteralToken. Checking the expression kind
|
|
// (StringLiteralExpression) covers all token kinds Roslyn assigns to literal
|
|
// strings, so a """raw""" path is harvested rather than mis-rejected as a
|
|
// dynamic path. (Core.Scripting-005.)
|
|
if (pathArg is not LiteralExpressionSyntax literal
|
|
|| !literal.IsKind(SyntaxKind.StringLiteralExpression))
|
|
{
|
|
_rejections.Add(new DependencyRejection(
|
|
Span: pathArg.Span,
|
|
Message: $"Tag path passed to ctx.{methodName} must be a string literal. " +
|
|
$"Dynamic paths (variables, concatenation, interpolation, method " +
|
|
$"calls) are rejected at publish so the dependency graph can be " +
|
|
$"built statically. Got: {pathArg.Kind()} ({pathArg})"));
|
|
return;
|
|
}
|
|
|
|
var path = (string?)literal.Token.Value ?? string.Empty;
|
|
if (string.IsNullOrWhiteSpace(path))
|
|
{
|
|
_rejections.Add(new DependencyRejection(
|
|
Span: literal.Span,
|
|
Message: $"Tag path passed to ctx.{methodName} is empty or whitespace."));
|
|
return;
|
|
}
|
|
|
|
if (methodName == nameof(ScriptContext.GetTag))
|
|
_reads.Add(path);
|
|
else
|
|
_writes.Add(path);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>Output of <see cref="DependencyExtractor.Extract"/>.</summary>
|
|
public sealed record DependencyExtractionResult(
|
|
IReadOnlySet<string> Reads,
|
|
IReadOnlySet<string> Writes,
|
|
IReadOnlyList<DependencyRejection> Rejections)
|
|
{
|
|
/// <summary>True when no rejections were recorded — safe to publish.</summary>
|
|
public bool IsValid => Rejections.Count == 0;
|
|
}
|
|
|
|
/// <summary>A single non-literal-path rejection with the exact source span for UI pointing.</summary>
|
|
public sealed record DependencyRejection(TextSpan Span, string Message);
|