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. /// /// /// Identifier matching is by spelling: the extractor looks for /// ctx.GetTag(...) / ctx.SetVirtualTag(...) literally. A deliberately /// misspelled method call (ctx.GetTagz) is not picked up but will also fail /// to compile against , so there's no way to smuggle a /// dependency past the extractor while still having a working script. /// /// 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 member-access form: ctx.GetTag(...) / ctx.SetVirtualTag(...). // Anything else (free functions, chained calls, static calls) is ignored — but // still visit children in case a ctx.GetTag call is nested inside. if (node.Expression is MemberAccessExpressionSyntax member) { 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; if (pathArg is not LiteralExpressionSyntax literal || !literal.Token.IsKind(SyntaxKind.StringLiteralToken)) { _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);