Skip to content

Commit f89cff7

Browse files
SimplyLizclaude
andcommitted
feat(lip): wire query_outgoing_impact through engine, CLI, and MCP
Surfaces LIP v2.3.5's forward call graph ("what does X call?") as a first-class CKB operation mirroring AnalyzeImpact in the reverse direction. Requires a daemon advertising query_outgoing_impact; degrades to an empty response with a provenance warning when the RPC isn't supported. - Engine: Engine.AnalyzeOutgoingImpact in internal/query, driven by the shared LIP fold pipeline via impact.FoldExternalCalleeItems. - impact: new FoldExternalCalleeItems twin of FoldExternalStaticItems, both delegating to a shared kind-parameterised fold. Fixed a latent distance-default bug that treated DirectCallee items as distance=2. - CLI: ckb impact outgoing <symbolId> with --min-score / --format. ProvenanceCLI gained a Warnings field so LIP-unavailable surfaces cleanly in JSON output. - MCP: analyzeOutgoingImpact tool registered and dispatched. Tests cover the engine method against a fake daemon, the fold-level kind tagging and dedup semantics, and the lip→impact seam end-to-end. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 136a7d8 commit f89cff7

11 files changed

Lines changed: 1074 additions & 10 deletions

cmd/ckb/impact.go

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,9 @@ var (
3939
// prepareChange subcommand flags
4040
prepareChangeFormat string
4141
prepareChangeChangeType string
42+
// outgoing subcommand flags
43+
impactOutgoingMinScore float32
44+
impactOutgoingFormat string
4245
)
4346

4447
var impactCmd = &cobra.Command{
@@ -75,6 +78,25 @@ Examples:
7578
Run: runPrepareChange,
7679
}
7780

81+
var impactOutgoingCmd = &cobra.Command{
82+
Use: "outgoing <symbolId>",
83+
Short: "Analyze what a symbol calls (forward call graph)",
84+
Long: `Analyze the forward call graph of a symbol — what it calls directly
85+
and transitively. Mirrors 'ckb impact <symbolId>' but in the opposite
86+
direction.
87+
88+
Requires a LIP daemon advertising query_outgoing_impact (LIP v2.3.5+).
89+
When LIP is unavailable the response carries the symbol metadata with
90+
empty callee lists and a provenance warning.
91+
92+
Examples:
93+
ckb impact outgoing DoWork
94+
ckb impact outgoing DoWork --min-score=0.6
95+
ckb impact outgoing DoWork --format=json`,
96+
Args: cobra.ExactArgs(1),
97+
Run: runImpactOutgoing,
98+
}
99+
78100
var impactDiffCmd = &cobra.Command{
79101
Use: "diff",
80102
Short: "Analyze impact of code changes",
@@ -112,8 +134,13 @@ func init() {
112134
prepareChangeCmd.Flags().StringVar(&prepareChangeFormat, "format", "full", "Output format (full, compact)")
113135
prepareChangeCmd.Flags().StringVar(&prepareChangeChangeType, "change-type", "modify", "Change type (modify, rename, delete, extract, move)")
114136

137+
// outgoing subcommand flags
138+
impactOutgoingCmd.Flags().Float32Var(&impactOutgoingMinScore, "min-score", 0.6, "Minimum cosine similarity for semantic callees (0 disables semantic enrichment)")
139+
impactOutgoingCmd.Flags().StringVar(&impactOutgoingFormat, "format", "human", "Output format (human, json)")
140+
115141
impactCmd.AddCommand(impactDiffCmd)
116142
impactCmd.AddCommand(prepareChangeCmd)
143+
impactCmd.AddCommand(impactOutgoingCmd)
117144
rootCmd.AddCommand(impactCmd)
118145
}
119146

@@ -805,3 +832,135 @@ func formatImpactMarkdown(resp *ChangeSetResponseCLI) string {
805832

806833
return b.String()
807834
}
835+
836+
// OutgoingImpactResponseCLI is the CLI-facing view of
837+
// query.AnalyzeOutgoingImpactResponse.
838+
type OutgoingImpactResponseCLI struct {
839+
SymbolID string `json:"symbolId"`
840+
Symbol *SymbolInfoCLI `json:"symbol,omitempty"`
841+
DirectCallees []ImpactItemCLI `json:"directCallees"`
842+
TransitiveCallees []ImpactItemCLI `json:"transitiveCallees,omitempty"`
843+
SemanticCallees []SemanticCalleeInfoCLI `json:"semanticCallees,omitempty"`
844+
EdgesSource string `json:"edgesSource,omitempty"`
845+
Truncated bool `json:"truncated,omitempty"`
846+
Provenance *ProvenanceCLI `json:"provenance,omitempty"`
847+
}
848+
849+
// SemanticCalleeInfoCLI represents an embedding-similar coupled callee.
850+
type SemanticCalleeInfoCLI struct {
851+
SymbolURI string `json:"symbolUri,omitempty"`
852+
FileURI string `json:"fileUri"`
853+
Similarity float32 `json:"similarity"`
854+
Source string `json:"source"`
855+
}
856+
857+
func runImpactOutgoing(cmd *cobra.Command, args []string) {
858+
start := time.Now()
859+
logger := newLogger(impactOutgoingFormat)
860+
symbolID := args[0]
861+
862+
repoRoot := mustGetRepoRoot()
863+
engine := mustGetEngine(repoRoot, logger)
864+
ctx := newContext()
865+
866+
resp, err := engine.AnalyzeOutgoingImpact(ctx, query.AnalyzeOutgoingImpactOptions{
867+
SymbolId: symbolID,
868+
MinScore: impactOutgoingMinScore,
869+
})
870+
if err != nil {
871+
if strings.Contains(err.Error(), "not found") {
872+
fmt.Fprint(os.Stderr, formatSymbolNotFoundError(symbolID))
873+
os.Exit(1)
874+
}
875+
fmt.Fprintf(os.Stderr, "Error analyzing outgoing impact: %v\n", err)
876+
os.Exit(1)
877+
}
878+
879+
cliResp := convertOutgoingImpactResponse(symbolID, resp)
880+
output, err := FormatResponse(cliResp, OutputFormat(impactOutgoingFormat))
881+
if err != nil {
882+
fmt.Fprintf(os.Stderr, "Error formatting output: %v\n", err)
883+
os.Exit(1)
884+
}
885+
fmt.Println(output)
886+
887+
logger.Debug("Outgoing impact analysis completed",
888+
"symbolId", symbolID,
889+
"direct", len(resp.DirectCallees),
890+
"transitive", len(resp.TransitiveCallees),
891+
"duration", time.Since(start).Milliseconds(),
892+
)
893+
}
894+
895+
func convertOutgoingImpactResponse(symbolID string, resp *query.AnalyzeOutgoingImpactResponse) *OutgoingImpactResponseCLI {
896+
direct := make([]ImpactItemCLI, 0, len(resp.DirectCallees))
897+
for _, item := range resp.DirectCallees {
898+
direct = append(direct, impactItemToCLI(item))
899+
}
900+
transitive := make([]ImpactItemCLI, 0, len(resp.TransitiveCallees))
901+
for _, item := range resp.TransitiveCallees {
902+
transitive = append(transitive, impactItemToCLI(item))
903+
}
904+
semantic := make([]SemanticCalleeInfoCLI, 0, len(resp.SemanticCallees))
905+
for _, s := range resp.SemanticCallees {
906+
semantic = append(semantic, SemanticCalleeInfoCLI{
907+
SymbolURI: s.SymbolURI,
908+
FileURI: s.FileURI,
909+
Similarity: s.Similarity,
910+
Source: s.Source,
911+
})
912+
}
913+
914+
out := &OutgoingImpactResponseCLI{
915+
SymbolID: symbolID,
916+
DirectCallees: direct,
917+
TransitiveCallees: transitive,
918+
SemanticCallees: semantic,
919+
EdgesSource: resp.EdgesSource,
920+
Truncated: resp.Truncated,
921+
}
922+
if resp.Symbol != nil {
923+
visibility := "unknown"
924+
confidence := 0.0
925+
if resp.Symbol.Visibility != nil {
926+
visibility = resp.Symbol.Visibility.Visibility
927+
confidence = resp.Symbol.Visibility.Confidence
928+
}
929+
out.Symbol = &SymbolInfoCLI{
930+
StableID: resp.Symbol.StableId,
931+
Name: resp.Symbol.Name,
932+
Kind: resp.Symbol.Kind,
933+
Visibility: visibility,
934+
VisibilityConfidence: confidence,
935+
}
936+
}
937+
if resp.Provenance != nil {
938+
out.Provenance = &ProvenanceCLI{
939+
RepoStateId: resp.Provenance.RepoStateId,
940+
RepoStateDirty: resp.Provenance.RepoStateDirty,
941+
QueryDurationMs: resp.Provenance.QueryDurationMs,
942+
Warnings: resp.Provenance.Warnings,
943+
}
944+
}
945+
return out
946+
}
947+
948+
func impactItemToCLI(item query.ImpactItem) ImpactItemCLI {
949+
cli := ImpactItemCLI{
950+
StableID: item.StableId,
951+
Name: item.Name,
952+
Kind: item.Kind,
953+
Distance: item.Distance,
954+
ModuleID: item.ModuleId,
955+
Confidence: item.Confidence,
956+
}
957+
if item.Location != nil {
958+
cli.Location = &LocationCLI{
959+
FileID: item.Location.FileId,
960+
Path: item.Location.FileId,
961+
StartLine: item.Location.StartLine,
962+
StartColumn: item.Location.StartColumn,
963+
}
964+
}
965+
return cli
966+
}

cmd/ckb/symbol.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -114,9 +114,10 @@ type ModuleInfoCLI struct {
114114

115115
// ProvenanceCLI contains response metadata
116116
type ProvenanceCLI struct {
117-
RepoStateId string `json:"repoStateId"`
118-
RepoStateDirty bool `json:"repoStateDirty"`
119-
QueryDurationMs int64 `json:"queryDurationMs"`
117+
RepoStateId string `json:"repoStateId"`
118+
RepoStateDirty bool `json:"repoStateDirty"`
119+
QueryDurationMs int64 `json:"queryDurationMs"`
120+
Warnings []string `json:"warnings,omitempty"`
120121
}
121122

122123
func convertSymbolResponse(resp *query.GetSymbolResponse) *SymbolResponseCLI {

internal/impact/enricher.go

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,32 @@ func FoldExternalStaticItems(
166166
direct, transitive []ImpactItem,
167167
external *ExternalBlastRadius,
168168
repoRoot string,
169+
) (foldedDirect, foldedTransitive []ImpactItem) {
170+
return foldExternalItemsWithKinds(direct, transitive, external, repoRoot, DirectCaller, TransitiveCaller)
171+
}
172+
173+
// FoldExternalCalleeItems is the forward-direction twin of
174+
// FoldExternalStaticItems, tagging folded items with DirectCallee /
175+
// TransitiveCallee. Used by Engine.AnalyzeOutgoingImpact to fold LIP's
176+
// query_outgoing_impact result into the shared ImpactItem pipeline.
177+
//
178+
// Unlike the incoming path there is typically no SCIP-derived list to merge
179+
// against — callers usually pass nil for direct/transitive and get back a
180+
// pure LIP-derived set. Dedup semantics are identical to the incoming fold,
181+
// so passing non-nil inputs is also supported for future SCIP forward BFS.
182+
func FoldExternalCalleeItems(
183+
direct, transitive []ImpactItem,
184+
external *ExternalBlastRadius,
185+
repoRoot string,
186+
) (foldedDirect, foldedTransitive []ImpactItem) {
187+
return foldExternalItemsWithKinds(direct, transitive, external, repoRoot, DirectCallee, TransitiveCallee)
188+
}
189+
190+
func foldExternalItemsWithKinds(
191+
direct, transitive []ImpactItem,
192+
external *ExternalBlastRadius,
193+
repoRoot string,
194+
directKind, transitiveKind ImpactKind,
169195
) (foldedDirect, foldedTransitive []ImpactItem) {
170196
if external == nil || external.EdgesSource == EdgesSourceEmpty {
171197
return direct, transitive
@@ -183,7 +209,7 @@ func FoldExternalStaticItems(
183209
foldedTransitive = transitive
184210

185211
for _, ei := range external.DirectItems {
186-
item, key, ok := externalItemToImpactItem(ei, DirectCaller)
212+
item, key, ok := externalItemToImpactItem(ei, directKind)
187213
if !ok {
188214
continue
189215
}
@@ -194,7 +220,7 @@ func FoldExternalStaticItems(
194220
foldedDirect = append(foldedDirect, item)
195221
}
196222
for _, ei := range external.TransitiveItems {
197-
item, key, ok := externalItemToImpactItem(ei, TransitiveCaller)
223+
item, key, ok := externalItemToImpactItem(ei, transitiveKind)
198224
if !ok {
199225
continue
200226
}
@@ -220,7 +246,7 @@ func externalItemToImpactItem(ei ExternalItem, kind ImpactKind) (ImpactItem, str
220246
}
221247
distance := ei.Distance
222248
if distance == 0 {
223-
if kind == DirectCaller {
249+
if kind == DirectCaller || kind == DirectCallee {
224250
distance = 1
225251
} else {
226252
distance = 2

internal/impact/enricher_test.go

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,70 @@ func TestFoldExternalStaticItems_DistanceDefault(t *testing.T) {
233233
}
234234
}
235235

236+
func TestFoldExternalCalleeItems_TagsCalleeKinds(t *testing.T) {
237+
// Mirrors the Caller test pattern, but asserts items are tagged with
238+
// DirectCallee / TransitiveCallee rather than the caller kinds. Guards
239+
// against accidental cross-wiring of foldExternalItemsWithKinds.
240+
external := &ExternalBlastRadius{
241+
EdgesSource: EdgesSourceScipWithTier1Edges,
242+
DirectItems: []ExternalItem{
243+
{SymbolURI: "lip://local//repo/a.go#A", Distance: 1, Confidence: 0.95},
244+
},
245+
TransitiveItems: []ExternalItem{
246+
{SymbolURI: "lip://local//repo/b.go#B", Distance: 2, Confidence: 0.85},
247+
},
248+
}
249+
gotD, gotT := FoldExternalCalleeItems(nil, nil, external, "/repo")
250+
if len(gotD) != 1 || gotD[0].Kind != DirectCallee {
251+
t.Errorf("direct kind = %q, want %q", gotD[0].Kind, DirectCallee)
252+
}
253+
if len(gotT) != 1 || gotT[0].Kind != TransitiveCallee {
254+
t.Errorf("transitive kind = %q, want %q", gotT[0].Kind, TransitiveCallee)
255+
}
256+
}
257+
258+
func TestFoldExternalCalleeItems_NilAndEmpty(t *testing.T) {
259+
// Short-circuits mirror the caller twin — nil external and "empty"
260+
// EdgesSource both must leave the input lists unchanged.
261+
seed := []ImpactItem{{Name: "seed", Kind: DirectCallee, Distance: 1}}
262+
263+
gotD, gotT := FoldExternalCalleeItems(seed, nil, nil, "/repo")
264+
if len(gotD) != 1 || len(gotT) != 0 {
265+
t.Errorf("nil external: direct=%d trans=%d, want 1/0", len(gotD), len(gotT))
266+
}
267+
268+
gotD, _ = FoldExternalCalleeItems(seed, nil, &ExternalBlastRadius{
269+
EdgesSource: EdgesSourceEmpty,
270+
DirectItems: []ExternalItem{
271+
{SymbolURI: "lip://local//repo/a.go#A", Distance: 1},
272+
},
273+
}, "/repo")
274+
if len(gotD) != 1 {
275+
t.Errorf("empty EdgesSource: direct=%d, want 1 (unchanged)", len(gotD))
276+
}
277+
}
278+
279+
func TestFoldExternalCalleeItems_DistanceDefault(t *testing.T) {
280+
// Distance=0 from LIP should default to 1 for direct, 2 for transitive —
281+
// same semantics as the caller path.
282+
external := &ExternalBlastRadius{
283+
EdgesSource: EdgesSourceScipOnly,
284+
DirectItems: []ExternalItem{
285+
{SymbolURI: "lip://local//repo/a.go#A", Confidence: 0.95},
286+
},
287+
TransitiveItems: []ExternalItem{
288+
{SymbolURI: "lip://local//repo/b.go#B", Confidence: 0.85},
289+
},
290+
}
291+
gotD, gotT := FoldExternalCalleeItems(nil, nil, external, "/repo")
292+
if gotD[0].Distance != 1 {
293+
t.Errorf("direct default distance = %d, want 1", gotD[0].Distance)
294+
}
295+
if gotT[0].Distance != 2 {
296+
t.Errorf("transitive default distance = %d, want 2", gotT[0].Distance)
297+
}
298+
}
299+
236300
func TestMergeBlastRadius_DedupByFile(t *testing.T) {
237301
static := &BlastRadius{
238302
UniqueCallerCount: 2,

internal/mcp/presets_test.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,9 +42,9 @@ func TestPresetFiltering(t *testing.T) {
4242
t.Fatalf("failed to set full preset: %v", err)
4343
}
4444
fullTools := server.GetFilteredTools()
45-
// v8.5: +3 Cartographer (shotgunSurgery, evolution, blastRadius) +3 LIP annotation tools = 107; +1 symbolExists = 108; +1 (full includes the expanded presets) = 109
46-
if len(fullTools) != 109 {
47-
t.Errorf("expected 109 full tools, got %d", len(fullTools))
45+
// v8.5: +3 Cartographer (shotgunSurgery, evolution, blastRadius) +3 LIP annotation tools = 107; +1 symbolExists = 108; +1 (full includes the expanded presets) = 109; +1 analyzeOutgoingImpact = 110
46+
if len(fullTools) != 110 {
47+
t.Errorf("expected 110 full tools, got %d", len(fullTools))
4848
}
4949

5050
// Full preset should still have core tools first

internal/mcp/token_budget_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ func TestToolsListTokenBudget(t *testing.T) {
3535
}{
3636
{PresetCore, maxCorePresetBytes, 20, 25}, // v8.3: 24 tools (+explainPath, responsibilities, exportForLLM); +1 symbolExists = 25
3737
{PresetReview, maxReviewPresetBytes, 30, 42}, // v8.4: 41 tools (+findUnwiredModules); +1 symbolExists = 42
38-
{PresetFull, maxFullPresetBytes, 80, 109}, // v8.5: 107 tools (+3 Cartographer, +3 LIP annotation); +1 symbolExists in all presets = 109
38+
{PresetFull, maxFullPresetBytes, 80, 110}, // v8.5: 107 tools (+3 Cartographer, +3 LIP annotation); +1 symbolExists in all presets = 109; +1 analyzeOutgoingImpact = 110
3939
}
4040

4141
for _, tt := range tests {

0 commit comments

Comments
 (0)