using System.Globalization; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; using ZB.MOM.WW.ScadaBridge.Commons.Types.Debugging; namespace ZB.MOM.WW.ScadaBridge.ScriptAnalysis; /// A node of a predicted flow (Flow Debugger design 2026-09-05 §5.5 — the "Predicted" state). /// Stable id within the flow (n1, n2, …). /// The step kind this node would produce when run. /// The swimlane it is drawn in. /// Display title — the route target, script name, attribute name, … /// True when the target is computed from inputs (not a string literal) and could not be predicted statically. /// Advisory text — an early-return guard that precedes this node, a depth cap, a parse error. public sealed record PredictedNode(string Id, FlowStepKind Kind, FlowLane Lane, string Title, bool InputDependent, string? Note); /// A directed edge between two predicted nodes. /// Source node id. /// Target node id. /// True when exactly one end sits in the Central lane (a central↔site hop). public sealed record PredictedEdge(string From, string To, bool CrossesBoundary); /// The predicted shape of a flow: nodes in source order plus the chain edges between them. public sealed record PredictedFlow(IReadOnlyList Nodes, IReadOnlyList Edges) { /// An empty prediction. public static readonly PredictedFlow Empty = new([], []); } /// /// Predicts the shape of an inbound-API or site-script flow from script syntax alone /// (Flow Debugger design 2026-09-05 §5.5): which routed calls, script calls, attribute writes /// and attribute waits the script would produce, in source order, before anything runs. /// /// /// This is a syntax walk, not a semantic one — it matches the same member-access chains the /// script API exposes (Route.To(x).Call(y), Scripts.Call(y), /// ….Attributes.WriteBatchAndWaitAsync(…), ….Attributes[x] = v, …) by text with /// whitespace stripped, exactly as 's hardening walker does. /// String-literal arguments resolve to titles; anything else is flagged /// . Branching is not modelled: every recognised call /// is emitted once in source order, and an if (…) return …; guard that precedes the next /// node is surfaced as a note on that node. The predicted node kinds are exactly the kinds a /// traced run records, so the page can overlay the real graph on the prediction by kind + title. /// /// /// /// Kept DB-free on purpose: a caller supplies resolveRouteScript(instanceCode, scriptName) /// to turn a route target into a site script body (Central UI resolves instance → template → /// script); this class only decides when to ask. /// /// public static class FlowShapePredictor { /// Default recursion cap — an inbound method's routed site script is depth 1, a nested Scripts.Call depth 2. public const int DefaultMaxDepth = 4; /// Note attached to the request node when the script does not parse. public const string ParseErrorNote = "parse error"; /// /// Predicts the flow of an inbound API method: an /// node first, the method script's calls, one hop into each routed site script the resolver /// can supply (recursively up to ), and a /// node last. /// /// The inbound method name (titles the request node). /// The method's C# script body. /// /// (instanceCode, scriptName) → the site script body, or null when it cannot be /// resolved. Called with the literal instance code when the route target is a constant, and /// with the target expression's source text otherwise (so a resolver may still answer for a /// known expression, and a test resolver may answer for anything). /// /// Recursion cap; nested script bodies beyond it are not expanded. public static PredictedFlow PredictInboundMethod( string methodName, string methodScript, Func resolveRouteScript, int maxDepth = DefaultMaxDepth) { ArgumentNullException.ThrowIfNull(resolveRouteScript); var b = new Builder(resolveRouteScript, Math.Max(1, maxDepth)); var request = b.AddNode(FlowStepKind.InboundRequest, FlowLane.Central, $"POST /api/{methodName}", inputDependent: false, note: null); try { if (!b.Walk(methodScript ?? string.Empty, FlowLane.Central, instanceCode: null, depth: 0)) b.Annotate(request, ParseErrorNote); } catch (Exception) { // A predictor must never take the page down; degrade to the request/response pair. b.Reset(keep: 1); b.Annotate(request, ParseErrorNote); } b.AddNode(FlowStepKind.Response, FlowLane.Central, "Response", inputDependent: false, note: null); return b.Build(); } /// /// Predicts the flow of a site script run: a node first, /// then the script's attribute writes/waits and nested calls. Nested Scripts.Call /// targets are not expanded (no resolver) — they appear as unexpanded ScriptRun nodes. /// /// The script name (titles the root node). /// The script's C# body. /// Recursion cap. public static PredictedFlow PredictSiteScript(string scriptName, string scriptBody, int maxDepth = DefaultMaxDepth) { var b = new Builder((_, _) => null, Math.Max(1, maxDepth)); var root = b.AddNode(FlowStepKind.ScriptRun, FlowLane.Site, scriptName, inputDependent: false, note: null); try { if (!b.Walk(scriptBody ?? string.Empty, FlowLane.Site, instanceCode: null, depth: 1)) b.Annotate(root, ParseErrorNote); } catch (Exception) { b.Reset(keep: 1); b.Annotate(root, ParseErrorNote); } return b.Build(); } /// Accumulates nodes/edges and owns the chain ("previous node") state across nested walks. private sealed class Builder { private readonly Func _resolve; private readonly int _maxDepth; private readonly List _nodes = []; private readonly List _edges = []; private string? _previous; private string? _pendingNote; internal Builder(Func resolve, int maxDepth) { _resolve = resolve; _maxDepth = maxDepth; } internal PredictedFlow Build() => new(_nodes.ToArray(), _edges.ToArray()); /// Adds a node, chains it to the previous one, and consumes any pending early-return note. internal string AddNode(FlowStepKind kind, FlowLane lane, string title, bool inputDependent, string? note) { var id = "n" + (_nodes.Count + 1).ToString(CultureInfo.InvariantCulture); var mergedNote = note ?? _pendingNote; if (note != null && _pendingNote != null) mergedNote = $"{_pendingNote}; {note}"; _pendingNote = null; _nodes.Add(new PredictedNode(id, kind, lane, title, inputDependent, mergedNote)); if (_previous != null) { var fromLane = _nodes.First(n => n.Id == _previous).Lane; _edges.Add(new PredictedEdge(_previous, id, (fromLane == FlowLane.Central) != (lane == FlowLane.Central))); } _previous = id; return id; } internal void Annotate(string id, string note) { var i = _nodes.FindIndex(n => n.Id == id); if (i < 0) return; var n = _nodes[i]; _nodes[i] = n with { Note = n.Note == null ? note : $"{n.Note}; {note}" }; } /// Drops everything after the first nodes (fault recovery). internal void Reset(int keep) { if (_nodes.Count > keep) _nodes.RemoveRange(keep, _nodes.Count - keep); var ids = _nodes.Select(n => n.Id).ToHashSet(StringComparer.Ordinal); _edges.RemoveAll(e => !ids.Contains(e.From) || !ids.Contains(e.To)); _previous = _nodes.Count > 0 ? _nodes[^1].Id : null; _pendingNote = null; } /// /// Walks one script body in the given lane. Returns false when the body has parse /// errors (nothing is emitted for it), true otherwise. /// internal bool Walk(string code, FlowLane lane, string? instanceCode, int depth) { var tree = CSharpSyntaxTree.ParseText(code, new CSharpParseOptions(kind: SourceCodeKind.Script)); if (tree.GetDiagnostics().Any(d => d.Severity == DiagnosticSeverity.Error)) return false; new Walker(this, lane, instanceCode, depth).Visit(tree.GetRoot()); _pendingNote = null; // a trailing guard with nothing after it has no node to annotate return true; } internal void QueueNote(string note) => _pendingNote = _pendingNote == null ? note : $"{_pendingNote}; {note}"; /// Expands a routed / nested script call one level, if the resolver can supply its body. internal void ExpandScript(string instanceCode, string scriptName, int depth) { if (depth >= _maxDepth) { Annotate(_previous!, "depth limit reached; not expanded"); return; } string? body; try { body = _resolve(instanceCode, scriptName); } catch (Exception) { body = null; } if (body == null) return; if (!Walk(body, FlowLane.Site, instanceCode, depth + 1)) Annotate(_previous!, ParseErrorNote); } } /// Source-order visitor that turns recognised script-API calls into nodes. private sealed class Walker : CSharpSyntaxWalker { private readonly Builder _b; private readonly FlowLane _lane; private readonly string? _instanceCode; private readonly int _depth; internal Walker(Builder b, FlowLane lane, string? instanceCode, int depth) { _b = b; _lane = lane; _instanceCode = instanceCode; _depth = depth; } private static string Strip(string text) => new(text.Where(c => !char.IsWhiteSpace(c)).ToArray()); /// /// An if (…) { … return …; } guard becomes a note on the node that FOLLOWS it. /// The condition is visited first (it may itself contain a traced call — the Delmia /// site script's if (!await …WriteBatchAndWaitAsync(…)) return …), then the /// body, and only then is the note queued for whatever comes next. /// public override void VisitIfStatement(IfStatementSyntax node) { Visit(node.Condition); Visit(node.Statement); if (node.Else != null) Visit(node.Else); var returnsEarly = node.Statement.DescendantNodesAndSelf().OfType().Any() || node.Statement.DescendantNodesAndSelf().OfType().Any(); if (returnsEarly) _b.QueueNote($"early return: {Condense(node.Condition.ToString())}"); } public override void VisitAssignmentExpression(AssignmentExpressionSyntax node) { // recv.Attributes["X"] = v / Attributes["X"] = v if (node.Left is ElementAccessExpressionSyntax ea && Strip(ea.Expression.ToString()) is var recv && (recv == "Attributes" || recv.EndsWith(".Attributes", StringComparison.Ordinal))) { var (attr, dependent) = LiteralOrExpr(ea.ArgumentList.Arguments.FirstOrDefault()?.Expression); _b.AddNode(FlowStepKind.AttributeWrite, FlowLane.Tags, Qualify(attr), dependent, null); } base.VisitAssignmentExpression(node); } public override void VisitInvocationExpression(InvocationExpressionSyntax node) { // Visit inner invocations first? No — source order for a chained call like // Route.To(x).Call(y) is the outer call; the inner To(x) is part of its target. // Nested invocations inside ARGUMENTS are visited after this node is emitted, // which is the order they would produce trace rows only when they are awaited // first — acceptable for a shape preview. var handled = TryHandle(node); if (!handled) base.VisitInvocationExpression(node); else foreach (var arg in node.ArgumentList.Arguments) Visit(arg.Expression); } private bool TryHandle(InvocationExpressionSyntax node) { if (node.Expression is not MemberAccessExpressionSyntax ma) return false; var member = ma.Name.Identifier.ValueText; var receiverText = Strip(ma.Expression.ToString()); var args = node.ArgumentList.Arguments; // Route.To().Call(