Skip to content

Commit cf7ff98

Browse files
SimplyLizclaude
andcommitted
feat(lip): add QueryOutgoingImpact client against v2.3.3 shape
Mirrors query_blast_radius_symbol for the forward direction. Returns an OutgoingImpactEntry with target_uri, direct_items (callees at distance=1), transitive_items (distance>=2), semantic_items (seeded from the target's embedding), edges_source, and truncated — the same provenance and coupling vocabulary as blast radius so folds can be routed through the shared ExternalBlastRadius pipeline. OutgoingEntryToExternal converts the wire entry to the shared shape with RiskLevel empty — outgoing impact doesn't carry its own classification; CKB will derive one from the unioned callee set when the analyzer path is wired. Adds DirectCallee/TransitiveCallee ImpactKinds so folded items classify correctly once the analyzer fold lands. Analyzer + CLI/MCP surface are deferred until LIP v2.3.3 ships and the wire contract is verified end-to-end. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 85fc898 commit cf7ff98

4 files changed

Lines changed: 234 additions & 0 deletions

File tree

internal/impact/classification.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ type ImpactKind string
66
const (
77
DirectCaller ImpactKind = "direct-caller"
88
TransitiveCaller ImpactKind = "transitive-caller"
9+
DirectCallee ImpactKind = "direct-callee" // forward direction: what this symbol calls
10+
TransitiveCallee ImpactKind = "transitive-callee" // forward direction, distance >= 2
911
TypeDependency ImpactKind = "type-dependency"
1012
TestDependency ImpactKind = "test-dependency"
1113
ImplementsInterface ImpactKind = "implements-interface"

internal/lip/blast_radius.go

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,3 +105,43 @@ func EntryToExternal(entry *BlastRadiusEntry) *impact.ExternalBlastRadius {
105105
return ebr
106106
}
107107

108+
// OutgoingEntryToExternal converts an OutgoingImpactEntry to
109+
// impact.ExternalBlastRadius so callers can reuse the same fold and merge
110+
// machinery built for incoming blast radius.
111+
//
112+
// The shared Go type does not imply shared semantics: direct_items here are
113+
// callees (symbols the target invokes), not callers. Consumers must classify
114+
// folded items with DirectCallee / TransitiveCallee kinds rather than
115+
// DirectCaller / TransitiveCaller.
116+
//
117+
// RiskLevel is intentionally left empty — outgoing impact doesn't carry its
118+
// own risk classification; CKB derives one from the unioned callee set on
119+
// receipt.
120+
func OutgoingEntryToExternal(entry *OutgoingImpactEntry) *impact.ExternalBlastRadius {
121+
if entry == nil {
122+
return nil
123+
}
124+
ebr := &impact.ExternalBlastRadius{
125+
EdgesSource: entry.EdgesSource,
126+
}
127+
for _, di := range entry.DirectItems {
128+
ebr.DirectItems = append(ebr.DirectItems, impact.ExternalItem{
129+
FileURI: di.FileURI, SymbolURI: di.SymbolURI,
130+
Distance: di.Distance, Confidence: di.Confidence,
131+
})
132+
}
133+
for _, ti := range entry.TransitiveItems {
134+
ebr.TransitiveItems = append(ebr.TransitiveItems, impact.ExternalItem{
135+
FileURI: ti.FileURI, SymbolURI: ti.SymbolURI,
136+
Distance: ti.Distance, Confidence: ti.Confidence,
137+
})
138+
}
139+
for _, si := range entry.SemanticItems {
140+
ebr.SemanticItems = append(ebr.SemanticItems, impact.ExternalSemanticItem{
141+
FileURI: si.FileURI, SymbolURI: si.SymbolURI,
142+
Similarity: si.Similarity, Source: si.Source,
143+
})
144+
}
145+
return ebr
146+
}
147+

internal/lip/client.go

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -856,6 +856,63 @@ func QueryBlastRadiusSymbol(symbolURI string, minScore float32) (*BlastRadiusEnt
856856
return raw.Result, nil
857857
}
858858

859+
// =============================================================================
860+
// Outgoing impact (v2.3.3)
861+
// =============================================================================
862+
863+
// OutgoingImpactEntry is the result of a QueryOutgoingImpact call. Shape
864+
// mirrors BlastRadiusEntry but traces the forward call graph — direct_items
865+
// are callees at distance=1, transitive_items at distance>=2.
866+
//
867+
// target_uri echoes the request's symbol_uri (post-canonicalisation).
868+
// edges_source and the semantic items reuse the same provenance and coupling
869+
// vocabulary as blast radius, so callers can treat both results through the
870+
// shared ExternalBlastRadius pipeline via OutgoingEntryToExternal.
871+
type OutgoingImpactEntry struct {
872+
TargetURI string `json:"target_uri"`
873+
DirectItems []BlastRadiusItem `json:"direct_items"`
874+
TransitiveItems []BlastRadiusItem `json:"transitive_items"`
875+
SemanticItems []BlastRadiusSemanticItem `json:"semantic_items"`
876+
// EdgesSource mirrors BlastRadiusEntry.EdgesSource: "tier1",
877+
// "scip_with_tier1_edges", "scip_only", or "empty". Omitted by
878+
// daemons that don't yet report provenance — treat as fold-eligible.
879+
EdgesSource string `json:"edges_source,omitempty"`
880+
Truncated bool `json:"truncated"`
881+
}
882+
883+
type outgoingImpactResp struct {
884+
Result *OutgoingImpactEntry `json:"result,omitempty"`
885+
}
886+
887+
// QueryOutgoingImpact asks LIP for the forward call graph of a symbol
888+
// (v2.3.3+). Returns (nil, nil) when the symbol isn't indexed, LIP is
889+
// unavailable, or the daemon doesn't support the RPC — callers should
890+
// degrade to SCIP-only outgoing traversal.
891+
//
892+
// Depth is capped at 8 server-side (matching query_outgoing_calls). Semantic
893+
// items are seeded from the target's own embedding, same as blast radius.
894+
//
895+
// Gate on Handshake.SupportedMessages containing "query_outgoing_impact"
896+
// before calling — older daemons reject with UnknownMessage.
897+
func QueryOutgoingImpact(symbolURI string, minScore float32) (*OutgoingImpactEntry, error) {
898+
if symbolURI == "" {
899+
return nil, nil
900+
}
901+
req := map[string]any{
902+
"type": "query_outgoing_impact",
903+
"symbol_uri": symbolURI,
904+
}
905+
if minScore > 0 {
906+
req["min_score"] = minScore
907+
}
908+
raw, _ := lipRPC(req, 2*time.Second, 2<<20,
909+
func(r outgoingImpactResp) *outgoingImpactResp { return &r })
910+
if raw == nil {
911+
return nil, nil
912+
}
913+
return raw.Result, nil
914+
}
915+
859916
// =============================================================================
860917
// Annotations
861918
// =============================================================================

internal/lip/client_test.go

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -741,6 +741,141 @@ func TestWireProtocol_RegisterProjectRoot_EmptyRoot(t *testing.T) {
741741
}
742742
}
743743

744+
// =============================================================================
745+
// Outgoing impact
746+
// =============================================================================
747+
748+
func TestWireProtocol_QueryOutgoingImpact_WithResult(t *testing.T) {
749+
d := newTestDaemon(t, outgoingImpactResp{Result: &OutgoingImpactEntry{
750+
TargetURI: "lip://local//repo/foo.go#Caller",
751+
DirectItems: []BlastRadiusItem{
752+
{FileURI: "lip://local//repo/bar.go", SymbolURI: "lip://local//repo/bar.go#Callee",
753+
Distance: 1, Confidence: 0.95},
754+
},
755+
TransitiveItems: []BlastRadiusItem{
756+
{FileURI: "lip://local//repo/baz.go", SymbolURI: "lip://local//repo/baz.go#Deep",
757+
Distance: 2, Confidence: 0.85},
758+
},
759+
SemanticItems: []BlastRadiusSemanticItem{
760+
{FileURI: "lip://local//repo/similar.go", SymbolURI: "...#Similar",
761+
Similarity: 0.82, Source: "semantic"},
762+
},
763+
EdgesSource: "scip_with_tier1_edges",
764+
Truncated: false,
765+
}})
766+
767+
got, err := QueryOutgoingImpact("lip://local//repo/foo.go#Caller", 0.6)
768+
d.waitHandled(t)
769+
770+
if err != nil {
771+
t.Fatalf("unexpected error: %v", err)
772+
}
773+
if got == nil {
774+
t.Fatal("got nil result")
775+
}
776+
if got.TargetURI != "lip://local//repo/foo.go#Caller" {
777+
t.Errorf("TargetURI = %q", got.TargetURI)
778+
}
779+
if len(got.DirectItems) != 1 || got.DirectItems[0].Distance != 1 {
780+
t.Errorf("DirectItems = %+v", got.DirectItems)
781+
}
782+
if len(got.TransitiveItems) != 1 || got.TransitiveItems[0].Distance != 2 {
783+
t.Errorf("TransitiveItems = %+v", got.TransitiveItems)
784+
}
785+
if len(got.SemanticItems) != 1 || got.SemanticItems[0].Source != "semantic" {
786+
t.Errorf("SemanticItems = %+v", got.SemanticItems)
787+
}
788+
if got.EdgesSource != "scip_with_tier1_edges" {
789+
t.Errorf("EdgesSource = %q", got.EdgesSource)
790+
}
791+
792+
req := d.req()
793+
assertField(t, req, "type", "query_outgoing_impact")
794+
assertField(t, req, "symbol_uri", "lip://local//repo/foo.go#Caller")
795+
assertField(t, req, "min_score", 0.6)
796+
}
797+
798+
// Null result (target not indexed) must come back as (nil, nil).
799+
func TestWireProtocol_QueryOutgoingImpact_NullResult(t *testing.T) {
800+
d := newTestDaemon(t, outgoingImpactResp{Result: nil})
801+
802+
got, err := QueryOutgoingImpact("lip://local//repo/unknown.go#X", 0.6)
803+
d.waitHandled(t)
804+
805+
if err != nil {
806+
t.Fatalf("unexpected error: %v", err)
807+
}
808+
if got != nil {
809+
t.Errorf("want nil result for unindexed target, got %+v", got)
810+
}
811+
}
812+
813+
// min_score=0 must be omitted so the daemon applies its default.
814+
func TestWireProtocol_QueryOutgoingImpact_OmitMinScore(t *testing.T) {
815+
d := newTestDaemon(t, outgoingImpactResp{})
816+
817+
_, _ = QueryOutgoingImpact("lip://x#X", 0)
818+
d.waitHandled(t)
819+
820+
req := d.req()
821+
assertField(t, req, "type", "query_outgoing_impact")
822+
assertField(t, req, "symbol_uri", "lip://x#X")
823+
assertNoField(t, req, "min_score")
824+
}
825+
826+
// Empty symbol URI must short-circuit — no socket call.
827+
func TestWireProtocol_QueryOutgoingImpact_EmptySymbol(t *testing.T) {
828+
prev := os.Getenv("LIP_SOCKET")
829+
os.Setenv("LIP_SOCKET", "/tmp/lip-nonexistent-ckb-test.sock")
830+
defer os.Setenv("LIP_SOCKET", prev)
831+
832+
got, err := QueryOutgoingImpact("", 0.6)
833+
if got != nil || err != nil {
834+
t.Errorf("empty symbol: want (nil, nil), got (%v, %v)", got, err)
835+
}
836+
}
837+
838+
func TestOutgoingEntryToExternal(t *testing.T) {
839+
entry := &OutgoingImpactEntry{
840+
TargetURI: "lip://x#X",
841+
DirectItems: []BlastRadiusItem{
842+
{FileURI: "f1", SymbolURI: "f1#A", Distance: 1, Confidence: 0.9},
843+
},
844+
TransitiveItems: []BlastRadiusItem{
845+
{FileURI: "f2", SymbolURI: "f2#B", Distance: 2, Confidence: 0.8},
846+
},
847+
SemanticItems: []BlastRadiusSemanticItem{
848+
{FileURI: "f3", SymbolURI: "f3#C", Similarity: 0.75, Source: "both"},
849+
},
850+
EdgesSource: "tier1",
851+
}
852+
ext := OutgoingEntryToExternal(entry)
853+
if ext == nil {
854+
t.Fatal("got nil")
855+
}
856+
if len(ext.DirectItems) != 1 || ext.DirectItems[0].SymbolURI != "f1#A" {
857+
t.Errorf("DirectItems: %+v", ext.DirectItems)
858+
}
859+
if len(ext.TransitiveItems) != 1 || ext.TransitiveItems[0].Distance != 2 {
860+
t.Errorf("TransitiveItems: %+v", ext.TransitiveItems)
861+
}
862+
if len(ext.SemanticItems) != 1 || ext.SemanticItems[0].Source != "both" {
863+
t.Errorf("SemanticItems: %+v", ext.SemanticItems)
864+
}
865+
if ext.EdgesSource != "tier1" {
866+
t.Errorf("EdgesSource = %q", ext.EdgesSource)
867+
}
868+
if ext.RiskLevel != "" {
869+
t.Errorf("RiskLevel should be empty for outgoing, got %q", ext.RiskLevel)
870+
}
871+
}
872+
873+
func TestOutgoingEntryToExternal_Nil(t *testing.T) {
874+
if got := OutgoingEntryToExternal(nil); got != nil {
875+
t.Errorf("nil entry: want nil, got %+v", got)
876+
}
877+
}
878+
744879
func TestWireProtocol_Handshake(t *testing.T) {
745880
d := newTestDaemon(t, handshakeResp{DaemonVersion: "2.0.0", ProtocolVersion: 2})
746881

0 commit comments

Comments
 (0)