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:
@@ -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