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(