|
39 | 39 | // prepareChange subcommand flags |
40 | 40 | prepareChangeFormat string |
41 | 41 | prepareChangeChangeType string |
| 42 | + // outgoing subcommand flags |
| 43 | + impactOutgoingMinScore float32 |
| 44 | + impactOutgoingFormat string |
42 | 45 | ) |
43 | 46 |
|
44 | 47 | var impactCmd = &cobra.Command{ |
@@ -75,6 +78,25 @@ Examples: |
75 | 78 | Run: runPrepareChange, |
76 | 79 | } |
77 | 80 |
|
| 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 | + |
78 | 100 | var impactDiffCmd = &cobra.Command{ |
79 | 101 | Use: "diff", |
80 | 102 | Short: "Analyze impact of code changes", |
@@ -112,8 +134,13 @@ func init() { |
112 | 134 | prepareChangeCmd.Flags().StringVar(&prepareChangeFormat, "format", "full", "Output format (full, compact)") |
113 | 135 | prepareChangeCmd.Flags().StringVar(&prepareChangeChangeType, "change-type", "modify", "Change type (modify, rename, delete, extract, move)") |
114 | 136 |
|
| 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 | + |
115 | 141 | impactCmd.AddCommand(impactDiffCmd) |
116 | 142 | impactCmd.AddCommand(prepareChangeCmd) |
| 143 | + impactCmd.AddCommand(impactOutgoingCmd) |
117 | 144 | rootCmd.AddCommand(impactCmd) |
118 | 145 | } |
119 | 146 |
|
@@ -805,3 +832,135 @@ func formatImpactMarkdown(resp *ChangeSetResponseCLI) string { |
805 | 832 |
|
806 | 833 | return b.String() |
807 | 834 | } |
| 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 | +} |
0 commit comments