using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; using Microsoft.CodeAnalysis.Text; namespace ZB.MOM.WW.OtOpcUa.Core.Scripting; /// /// Parses a script's source text + extracts every ctx.GetTag("literal") and /// ctx.SetVirtualTag("literal", ...) 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). /// /// /// /// 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. /// /// /// Matching is by spelling: the extractor looks for member-access invocations /// whose receiver identifier is literally ctx and whose method name is /// GetTag or SetVirtualTag. A deliberately misspelled method call /// (ctx.GetTagz) is not picked up but will also fail to compile against /// , 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 (other.GetTag("X")) are explicitly ignored so that /// scripts defining local helper types with matching names do not produce spurious /// dependencies. (Core.Scripting-004.) /// /// public static class DependencyExtractor { /// /// Parse + return the inferred read + write tag /// paths, or a list of rejection messages if non-literal paths were used. /// public static DependencyExtractionResult Extract(string scriptSource) { if (string.IsNullOrWhiteSpace(scriptSource)) return new DependencyExtractionResult( Reads: new HashSet(StringComparer.Ordinal), Writes: new HashSet(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 _reads = new(StringComparer.Ordinal); private readonly HashSet _writes = new(StringComparer.Ordinal); private readonly List _rejections = []; public IReadOnlySet Reads => _reads; public IReadOnlySet Writes => _writes; public IReadOnlyList 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.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); } } } /// Output of . public sealed record DependencyExtractionResult( IReadOnlySet Reads, IReadOnlySet Writes, IReadOnlyList Rejections) { /// True when no rejections were recorded — safe to publish. public bool IsValid => Rejections.Count == 0; } /// A single non-literal-path rejection with the exact source span for UI pointing. public sealed record DependencyRejection(TextSpan Span, string Message);