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:
Joseph Doherty
2026-09-06 01:51:42 -04:00
parent f394f58405
commit 3b9def5a09
3 changed files with 175 additions and 22 deletions
@@ -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]