9cad9ed0fc
v2-ci / build (push) Failing after 41s
v2-ci / unit-tests (tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests) (push) Has been skipped
Adds <summary>/<param>/<returns>/<inheritdoc> where missing and removes project bookkeeping IDs (task/tracking refs) from shipped code comments, so the docs read cleanly and CommentChecker is quiet except for known false positives (PLC/protocol terms, event/IEqualityComparer inheritdoc). Doc/comment-only; no logic changed; solution builds clean.
158 lines
7.4 KiB
C#
158 lines
7.4 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 (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.
|
|
/// </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>
|
|
/// <param name="scriptSource">The script source code to analyze.</param>
|
|
/// <returns>The inferred read/write tag dependencies, plus any non-literal-path rejections.</returns>
|
|
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 = [];
|
|
|
|
/// <summary>Gets the set of tags read by the script.</summary>
|
|
public IReadOnlySet<string> Reads => _reads;
|
|
/// <summary>Gets the set of tags written by the script.</summary>
|
|
public IReadOnlySet<string> Writes => _writes;
|
|
/// <summary>Gets the list of rejections for non-literal tag paths.</summary>
|
|
public IReadOnlyList<DependencyRejection> Rejections => _rejections;
|
|
|
|
/// <inheritdoc />
|
|
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.
|
|
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.
|
|
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);
|