feat(script-analysis): predict outbound calls as ApiCall/DbCall/Notify on the External lane
FlowShapePredictor emits ExternalSystem.Call/CachedCall → ApiCall ('{s}.{m}'),
Database.Execute/Query/QuerySingle/CachedWrite → DbCall ('{conn} · {member}'),
Notify.Send(list) and Notify.To(list).Send() → Notify ('Notify {list}'), all on
FlowLane.External; Async-suffixed spellings map identically. PredictedEdge gains
an additive Async flag; an async edge (cached call, notify) does not advance the
chain, mirroring FlowGraphBuilder.IsAsync (D4/D8). Database.Connection() raw
handles no longer predict a node (command count is not statically knowable).
Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29
This commit is contained in:
@@ -26,7 +26,7 @@
|
||||
{
|
||||
"id": 3,
|
||||
"subject": "Task 3: Site runtime \u2014 outbound rows flow through the trace collector",
|
||||
"status": "in_progress",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
0
|
||||
]
|
||||
@@ -50,7 +50,7 @@
|
||||
{
|
||||
"id": 6,
|
||||
"subject": "Task 6: FlowShapePredictor \u2014 three kinds on the External lane, async edges",
|
||||
"status": "pending",
|
||||
"status": "in_progress",
|
||||
"blockedBy": [
|
||||
0
|
||||
]
|
||||
@@ -58,7 +58,7 @@
|
||||
{
|
||||
"id": 7,
|
||||
"subject": "Task 7: FlowGraphView \u2014 External lane, status pills, async/spawn edge styles, pending state",
|
||||
"status": "pending",
|
||||
"status": "in_progress",
|
||||
"blockedBy": [
|
||||
0,
|
||||
1,
|
||||
@@ -96,5 +96,5 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"lastUpdated": "2026-09-06T05:47:05.104042+00:00"
|
||||
"lastUpdated": "2026-09-06T05:51:19.355720+00:00"
|
||||
}
|
||||
@@ -19,7 +19,12 @@ public sealed record PredictedNode(string Id, FlowStepKind Kind, FlowLane Lane,
|
||||
/// <param name="From">Source node id.</param>
|
||||
/// <param name="To">Target node id.</param>
|
||||
/// <param name="CrossesBoundary">True when exactly one end sits in the Central lane (a central↔site hop).</param>
|
||||
public sealed record PredictedEdge(string From, string To, bool CrossesBoundary);
|
||||
/// <param name="Async">
|
||||
/// True when the target is a fire-and-forget branch (a cached call or a notification, D4): the edge
|
||||
/// hangs off the chain step before it and the chain continues from that same step, mirroring
|
||||
/// <see cref="FlowGraphBuilder.IsAsync"/> on a real run.
|
||||
/// </param>
|
||||
public sealed record PredictedEdge(string From, string To, bool CrossesBoundary, bool Async = false);
|
||||
|
||||
/// <summary>The predicted shape of a flow: nodes in source order plus the chain edges between them.</summary>
|
||||
public sealed record PredictedFlow(IReadOnlyList<PredictedNode> Nodes, IReadOnlyList<PredictedEdge> Edges)
|
||||
@@ -30,8 +35,10 @@ public sealed record PredictedFlow(IReadOnlyList<PredictedNode> Nodes, IReadOnly
|
||||
|
||||
/// <summary>
|
||||
/// Predicts the shape of an inbound-API or site-script flow from script <em>syntax</em> 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.
|
||||
/// (Flow Debugger design 2026-09-05 §5.5): which routed calls, script calls, attribute writes,
|
||||
/// attribute waits and outbound calls (API, database, notification — on the
|
||||
/// <see cref="FlowLane.External"/> lane, plan 2026-09-06 D8) the script would produce, in source
|
||||
/// order, before anything runs.
|
||||
///
|
||||
/// <para>
|
||||
/// This is a syntax walk, not a semantic one — it matches the same member-access chains the
|
||||
@@ -145,8 +152,13 @@ public static class FlowShapePredictor
|
||||
|
||||
internal PredictedFlow Build() => new(_nodes.ToArray(), _edges.ToArray());
|
||||
|
||||
/// <summary>Adds a node, chains it to the previous one, and consumes any pending early-return note.</summary>
|
||||
internal string AddNode(FlowStepKind kind, FlowLane lane, string title, bool inputDependent, string? note)
|
||||
/// <summary>
|
||||
/// Adds a node, chains it to the previous one, and consumes any pending early-return note.
|
||||
/// An <paramref name="async"/> node hangs off the previous node on an <see cref="PredictedEdge.Async"/>
|
||||
/// edge and does NOT become the new chain head — the next node still links from the previous
|
||||
/// non-async node (D4: a cached call or notification returns immediately, so the run continues without it).
|
||||
/// </summary>
|
||||
internal string AddNode(FlowStepKind kind, FlowLane lane, string title, bool inputDependent, string? note, bool async = false)
|
||||
{
|
||||
var id = "n" + (_nodes.Count + 1).ToString(CultureInfo.InvariantCulture);
|
||||
var mergedNote = note ?? _pendingNote;
|
||||
@@ -158,10 +170,11 @@ public static class FlowShapePredictor
|
||||
if (_previous != null)
|
||||
{
|
||||
var fromLane = _nodes.First(n => n.Id == _previous).Lane;
|
||||
_edges.Add(new PredictedEdge(_previous, id, (fromLane == FlowLane.Central) != (lane == FlowLane.Central)));
|
||||
_edges.Add(new PredictedEdge(_previous, id, (fromLane == FlowLane.Central) != (lane == FlowLane.Central), async));
|
||||
}
|
||||
|
||||
_previous = id;
|
||||
if (!async)
|
||||
_previous = id;
|
||||
return id;
|
||||
}
|
||||
|
||||
@@ -381,25 +394,60 @@ public static class FlowShapePredictor
|
||||
}
|
||||
}
|
||||
|
||||
// ExternalSystem.Call / CachedCall, Database.*, Notify.Send — shown, never chained across.
|
||||
if (receiverText == "ExternalSystem" && member is "Call" or "CachedCall")
|
||||
// Outbound calls (plan 2026-09-06 D8): ApiCall / DbCall / Notify on the External lane. A sync
|
||||
// call blocks the run and sits in the chain; CachedCall / CachedWrite / Notify.Send return at
|
||||
// once and branch off it on an Async edge (D4). "…Async"-suffixed members are the same calls.
|
||||
var op = TrimAsyncSuffix(member);
|
||||
|
||||
// ExternalSystem.Call(system, method, …) / CachedCall(system, method, …)
|
||||
if (receiverText == "ExternalSystem" && op is "Call" or "CachedCall")
|
||||
{
|
||||
var (system, dependent) = LiteralOrExpr(args.FirstOrDefault()?.Expression);
|
||||
_b.AddNode(FlowStepKind.ApiCall, _lane, $"ExternalSystem.{member}({system})", dependent, null);
|
||||
var (system, systemDependent) = LiteralOrExpr(args.ElementAtOrDefault(0)?.Expression);
|
||||
var (method, methodDependent) = LiteralOrExpr(args.ElementAtOrDefault(1)?.Expression);
|
||||
var dependent = systemDependent || methodDependent;
|
||||
var title = dependent ? $"ExternalSystem.{op}({system}, {method})" : $"{system}.{method}";
|
||||
var cached = op == "CachedCall";
|
||||
_b.AddNode(FlowStepKind.ApiCall, FlowLane.External, title, dependent, cached ? CachedNote : null, async: cached);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (receiverText == "Database")
|
||||
// Database.Execute/Query/QuerySingle(connection, sql, …) — sync, in chain;
|
||||
// Database.CachedWrite(connection, sql, …) — store-and-forward, async branch.
|
||||
if (receiverText == "Database" && op is "Execute" or "Query" or "QuerySingle" or "CachedWrite")
|
||||
{
|
||||
var (conn, dependent) = LiteralOrExpr(args.FirstOrDefault()?.Expression);
|
||||
_b.AddNode(FlowStepKind.DbCall, _lane, $"Database.{member}({conn})", dependent, null);
|
||||
var title = dependent ? $"Database.{op}({conn})" : $"{conn} · {op}";
|
||||
var cached = op == "CachedWrite";
|
||||
_b.AddNode(FlowStepKind.DbCall, FlowLane.External, title, dependent, cached ? CachedNote : null, async: cached);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (receiverText == "Notify" && member == "Send")
|
||||
// Notify.To(list).Send(subject, message) — the site script API's shape — and the flat
|
||||
// Notify.Send(list, …) form. Enqueue-only, so always an async branch.
|
||||
if (op == "Send")
|
||||
{
|
||||
_b.AddNode(FlowStepKind.Notify, _lane, "Notify.Send", false, null);
|
||||
return true;
|
||||
ExpressionSyntax? listExpr = null;
|
||||
var isNotify = false;
|
||||
if (receiverText == "Notify")
|
||||
{
|
||||
isNotify = true;
|
||||
listExpr = args.FirstOrDefault()?.Expression;
|
||||
}
|
||||
else if (ma.Expression is InvocationExpressionSyntax to
|
||||
&& to.Expression is MemberAccessExpressionSyntax toMa
|
||||
&& Strip(toMa.ToString()) == "Notify.To")
|
||||
{
|
||||
isNotify = true;
|
||||
listExpr = to.ArgumentList.Arguments.FirstOrDefault()?.Expression;
|
||||
}
|
||||
|
||||
if (isNotify)
|
||||
{
|
||||
var (list, dependent) = LiteralOrExpr(listExpr);
|
||||
var title = dependent ? $"Notify.Send({list})" : $"Notify {list}";
|
||||
_b.AddNode(FlowStepKind.Notify, FlowLane.External, title, dependent, null, async: true);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
@@ -408,6 +456,13 @@ public static class FlowShapePredictor
|
||||
private string Qualify(string name)
|
||||
=> _instanceCode == null ? name : $"{_instanceCode}.{name}";
|
||||
|
||||
/// <summary>Note on a store-and-forward node (<c>CachedCall</c> / <c>CachedWrite</c>) — mirrors the projector's "cached" subtitle.</summary>
|
||||
private const string CachedNote = "cached";
|
||||
|
||||
/// <summary><c>CallAsync</c> → <c>Call</c>; the script API exposes both spellings for the same operation.</summary>
|
||||
private static string TrimAsyncSuffix(string member)
|
||||
=> member.Length > 5 && member.EndsWith("Async", StringComparison.Ordinal) ? member[..^5] : member;
|
||||
|
||||
/// <summary>A string literal resolves to its value; anything else is input-dependent and shown as its source text.</summary>
|
||||
private static (string Text, bool Dependent) LiteralOrExpr(ExpressionSyntax? expr)
|
||||
{
|
||||
|
||||
@@ -199,11 +199,109 @@ public class FlowShapePredictorTests
|
||||
(FlowStepKind.ScriptRun, "Handshake"),
|
||||
(FlowStepKind.AttributeWrite, "Cmd"),
|
||||
(FlowStepKind.AttributeWait, "Ack"),
|
||||
(FlowStepKind.ApiCall, "ExternalSystem.Call(Mes)"),
|
||||
(FlowStepKind.Notify, "Notify.Send"),
|
||||
(FlowStepKind.ApiCall, "Mes.Post"),
|
||||
(FlowStepKind.Notify, "Notify ops"),
|
||||
], flow.Nodes.Select(n => (n.Kind, n.Title)).ToList());
|
||||
Assert.DoesNotContain(flow.Nodes, n => n.Lane == FlowLane.Central);
|
||||
Assert.All(flow.Edges, e => Assert.False(e.CrossesBoundary));
|
||||
Assert.Equal(FlowLane.External, flow.Nodes[3].Lane);
|
||||
Assert.Equal(FlowLane.External, flow.Nodes[4].Lane);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OutboundCalls_SitOnTheExternalLane_SyncInChain_CachedAndNotifyBranchAsync()
|
||||
{
|
||||
// D4/D8: a sync call blocks the run (in-chain); CachedCall/CachedWrite/Notify return at once, so
|
||||
// they hang off the chain on Async edges and the chain continues from the last sync node.
|
||||
const string body = """
|
||||
var r = await ExternalSystem.CallAsync("Delmia", "AckRecipe", new { A = 1 });
|
||||
await Database.CachedWriteAsync("MesDb", "INSERT INTO t VALUES (1)");
|
||||
await Notify.SendAsync("Planners", "Recipe acked", "done");
|
||||
await Database.ExecuteAsync("MesDb", "UPDATE t SET x = 1");
|
||||
""";
|
||||
|
||||
var flow = FlowShapePredictor.PredictSiteScript("Handshake", body);
|
||||
|
||||
Assert.Equal(
|
||||
[
|
||||
(FlowStepKind.ScriptRun, FlowLane.Site, "Handshake"),
|
||||
(FlowStepKind.ApiCall, FlowLane.External, "Delmia.AckRecipe"),
|
||||
(FlowStepKind.DbCall, FlowLane.External, "MesDb · CachedWrite"),
|
||||
(FlowStepKind.Notify, FlowLane.External, "Notify Planners"),
|
||||
(FlowStepKind.DbCall, FlowLane.External, "MesDb · Execute"),
|
||||
], flow.Nodes.Select(n => (n.Kind, n.Lane, n.Title)).ToList());
|
||||
Assert.All(flow.Nodes, n => Assert.False(n.InputDependent));
|
||||
Assert.Null(flow.Nodes[1].Note);
|
||||
Assert.Equal("cached", flow.Nodes[2].Note);
|
||||
Assert.Null(flow.Nodes[3].Note);
|
||||
|
||||
var (run, api, cached, notify, exec) = (flow.Nodes[0].Id, flow.Nodes[1].Id, flow.Nodes[2].Id, flow.Nodes[3].Id, flow.Nodes[4].Id);
|
||||
Assert.Equal(
|
||||
[
|
||||
(run, api, false),
|
||||
(api, cached, true),
|
||||
(api, notify, true),
|
||||
(api, exec, false),
|
||||
], flow.Edges.Select(e => (e.From, e.To, e.Async)).ToList());
|
||||
Assert.All(flow.Edges, e => Assert.False(e.CrossesBoundary));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CachedCall_IsAnAsyncApiCall_AndNotifyTo_ResolvesTheList()
|
||||
{
|
||||
// The site script API's real Notify shape is Notify.To(list).Send(subject, message).
|
||||
const string body = """
|
||||
await ExternalSystem.CachedCall("Delmia", "AckRecipe");
|
||||
await Notify.To("Planners").Send("s", "m");
|
||||
""";
|
||||
|
||||
var flow = FlowShapePredictor.PredictSiteScript("S", body);
|
||||
|
||||
Assert.Equal(
|
||||
[
|
||||
(FlowStepKind.ScriptRun, "S", (string?)null),
|
||||
(FlowStepKind.ApiCall, "Delmia.AckRecipe", "cached"),
|
||||
(FlowStepKind.Notify, "Notify Planners", null),
|
||||
], flow.Nodes.Select(n => (n.Kind, n.Title, n.Note)).ToList());
|
||||
Assert.All(flow.Nodes.Skip(1), n => Assert.Equal(FlowLane.External, n.Lane));
|
||||
// Both branch off the run: neither advances the chain.
|
||||
Assert.Equal([(flow.Nodes[0].Id, true), (flow.Nodes[0].Id, true)], flow.Edges.Select(e => (e.From, e.Async)).ToList());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OutboundCalls_WithComputedTargets_AreInputDependent()
|
||||
{
|
||||
const string body = """
|
||||
await ExternalSystem.Call(sys, "M");
|
||||
await Database.QueryAsync(conn, "SELECT 1");
|
||||
await Notify.Send(list, "s");
|
||||
""";
|
||||
|
||||
var flow = FlowShapePredictor.PredictSiteScript("S", body);
|
||||
|
||||
var outbound = flow.Nodes.Skip(1).ToList();
|
||||
Assert.All(outbound, n => Assert.True(n.InputDependent));
|
||||
Assert.Equal("ExternalSystem.Call(sys, M)", outbound[0].Title);
|
||||
Assert.Equal("Database.Query(conn)", outbound[1].Title);
|
||||
Assert.Equal("Notify.Send(list)", outbound[2].Title);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InboundMethod_DatabaseCall_CrossesFromCentralToExternal()
|
||||
{
|
||||
const string script = """
|
||||
var rows = await Database.QuerySingleAsync<int>("ConfigDb", "SELECT 1");
|
||||
return rows;
|
||||
""";
|
||||
|
||||
var flow = FlowShapePredictor.PredictInboundMethod("M", script, AnySiteScript);
|
||||
|
||||
var db = flow.Nodes.Single(n => n.Kind == FlowStepKind.DbCall);
|
||||
Assert.Equal(FlowLane.External, db.Lane);
|
||||
Assert.Equal("ConfigDb · QuerySingle", db.Title);
|
||||
var into = flow.Edges.Single(e => e.To == db.Id);
|
||||
Assert.True(into.CrossesBoundary);
|
||||
Assert.False(into.Async);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
Reference in New Issue
Block a user