From b73d6b5e2e292e26f55a64eafed4138335bc9e9e Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 6 Sep 2026 00:15:42 -0400 Subject: [PATCH] =?UTF-8?q?feat(script-analysis):=20FlowShapePredictor=20?= =?UTF-8?q?=E2=80=94=20predicted=20flow=20graph=20from=20script=20syntax?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pure Roslyn syntax walk (SourceCodeKind.Script, no semantic model, DB-free) that turns a method or site script into PredictedNode/PredictedEdge chains in source order: Route.To(x) .Call/SetAttributes/WaitForAttribute, Scripts.Call, Attributes.WriteBatchAndWaitAsync/ WaitAsync/WaitForAsync/SetAsync, Attributes[x] = v, ExternalSystem/Database/Notify. String literals resolve; anything else is InputDependent with the expression text as title. An if-return guard becomes a note on the node that follows it (condition visited first, so a guard whose condition IS the traced call — Delmia's WriteBatchAndWaitAsync — attributes correctly). Routed/nested script bodies come from a caller-supplied resolver, recursion capped by maxDepth; parse errors and any walker fault degrade to request+response with a 'parse error' note. Predicted kinds are exactly the traced kinds so the page can overlay by kind + title. Deviation from the plan's node count: no separate central 'method script' node — the real graph records none, so the InboundRequest node stands for it. Claude-Session: https://claude.ai/code/session_01GSLkkHKz9JrAtmJjmQK3cP --- .../FlowShapePredictor.cs | 427 ++++++++++++++++++ .../FlowShapePredictorTests.cs | 218 +++++++++ 2 files changed, 645 insertions(+) create mode 100644 src/ZB.MOM.WW.ScadaBridge.ScriptAnalysis/FlowShapePredictor.cs create mode 100644 tests/ZB.MOM.WW.ScadaBridge.ScriptAnalysis.Tests/FlowShapePredictorTests.cs diff --git a/src/ZB.MOM.WW.ScadaBridge.ScriptAnalysis/FlowShapePredictor.cs b/src/ZB.MOM.WW.ScadaBridge.ScriptAnalysis/FlowShapePredictor.cs new file mode 100644 index 00000000..7bd313a5 --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.ScriptAnalysis/FlowShapePredictor.cs @@ -0,0 +1,427 @@ +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(