Skip to content

Commit bf844bf

Browse files
authored
Merge pull request #140 from HexmosTech/feat/rename-aware-blast-graph
Show pre-rename callers in the blast-radius dependency graph
2 parents 3d58127 + ce26f72 commit bf844bf

11 files changed

Lines changed: 707 additions & 120 deletions

File tree

blastradius/blastradius.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,12 @@ type CallerRef struct {
154154
// Path holds the intermediate qualified names between the symbol and
155155
// this caller (see CallerContribution.Path).
156156
Path []string
157+
// PreRename marks a caller reached via the symbol's pre-rename name
158+
// rather than the graph's CALLS edges: it references the old name and
159+
// breaks until it's migrated. CALLS fan-in can never find these - the
160+
// index reflects the post-rename tree, where the old name has no node
161+
// and every edge pointing at it was dropped as unresolvable.
162+
PreRename bool
157163
}
158164

159165
// SymbolContribution describes one touched symbol's part of a hunk's score.
@@ -182,7 +188,14 @@ type SymbolContribution struct {
182188
TransitiveCount int
183189
// Callers is the full caller list (only for "calls"), sorted by depth
184190
// then name, for showing exactly which transitive calls contributed.
191+
// A renamed symbol additionally carries its pre-rename callers here,
192+
// flagged with CallerRef.PreRename - see RenamedFrom.
185193
Callers []CallerRef
194+
// RenamedFrom is the pre-rename name when this symbol's own declaration
195+
// was renamed in the diff, empty otherwise. It explains why the entries
196+
// in Callers flagged PreRename are there: they still reference this
197+
// name, so they break until they're migrated.
198+
RenamedFrom string
186199
// ImpactedPackages are the distinct packages/directories this symbol's
187200
// influence reaches: for "calls", the packages its callers live in; for
188201
// "text-references", the directories search_code found matches in.
@@ -632,6 +645,17 @@ func ScoreHunks(ctx context.Context, project string, hunks []Hunk, opts ...Optio
632645
impactedPackages := sortedUnique(packages)
633646

634647
rename, renamed := renameByQN[qn]
648+
// A renamed symbol's CALLS fan-in above only sees callers already
649+
// migrated to the new name - usually none, which is why the graph
650+
// looked empty for exactly the highest-risk case. The pre-rename
651+
// callers are the ones about to break, so append them (display and
652+
// visualization only; they contribute no points - the rename's score
653+
// stays entirely in renameOldNameSignal below).
654+
if renamed {
655+
preRename := oldNameCallers(ctx, c, rename, qn)
656+
callers = append(callers, preRename...)
657+
callers = append(callers, expandPreRenameCallers(ctx, c, preRename, o.Score, qn)...)
658+
}
635659
callerReachDetail := fmt.Sprintf("%d direct + %d transitive caller(s), up to %d hops", direct, transitive, maxDepth)
636660
if renamed {
637661
callerReachDetail = fmt.Sprintf("%d direct + %d transitive caller(s) already migrated to the new name %q, up to %d hops", direct, transitive, rename.NewName, maxDepth)
@@ -706,6 +730,7 @@ func ScoreHunks(ctx context.Context, project string, hunks []Hunk, opts ...Optio
706730
DirectCount: direct,
707731
TransitiveCount: transitive,
708732
Callers: callers,
733+
RenamedFrom: rename.OldName, // zero value when !renamed
709734
ImpactedPackages: impactedPackages,
710735
}
711736
}
@@ -818,11 +843,24 @@ func ScoreHunks(ctx context.Context, project string, hunks []Hunk, opts ...Optio
818843
}
819844
}
820845

846+
// A renamed type has no call graph of its own (that's what makes
847+
// it a "text-references" symbol), but the places still using its
848+
// old name are real, locatable nodes - so a renamed struct gets a
849+
// dependency graph here where it previously had nothing to show.
850+
var callers []CallerRef
851+
if renamed {
852+
preRename := oldNameCallers(ctx, c, rename, qn)
853+
callers = append(callers, preRename...)
854+
callers = append(callers, expandPreRenameCallers(ctx, c, preRename, o.Score, qn)...)
855+
}
856+
821857
contribByQN[qn] = SymbolContribution{
822858
Method: "text-references",
823859
Signals: signals,
824860
BlastRadiusRaw: sumSignalPoints(signals, blastRadiusCategories),
825861
DirectCount: refs,
862+
Callers: callers,
863+
RenamedFrom: rename.OldName, // zero value when !renamed
826864
ImpactedPackages: usage.Directories,
827865
MethodBlastRadius: methodBlastRadius,
828866
}

blastradius/client/client.go

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,64 @@ func (c *Client) SearchCodeUsage(ctx context.Context, pattern string) (*CodeUsag
158158
return &CodeUsage{TotalMatches: result.TotalGrepMatches, Directories: dirs}, nil
159159
}
160160

161+
// CodeSymbolMatch is one textual hit resolved to the graph node that encloses
162+
// it - search_code's "compact" mode maps each grep match back to the symbol
163+
// whose source range contains it, which is what makes it usable as a caller
164+
// list for names that have no node of their own (see SearchCodeSymbols).
165+
type CodeSymbolMatch struct {
166+
QualifiedName string `json:"qualified_name"`
167+
Name string `json:"node"`
168+
Label string `json:"label"`
169+
File string `json:"file"`
170+
StartLine int `json:"start_line"`
171+
// MatchLines are the 1-based line numbers inside this symbol where the
172+
// pattern actually occurred.
173+
MatchLines []int `json:"match_lines"`
174+
}
175+
176+
// CodeSymbolMatches is the result of SearchCodeSymbols.
177+
type CodeSymbolMatches struct {
178+
// Matches are the enclosing symbols, deduplicated by the tool itself
179+
// (one entry per symbol, however many lines inside it matched).
180+
Matches []CodeSymbolMatch
181+
// TotalMatches is the raw grep-style hit count, the same number
182+
// SearchCodeUsage reports - unaffected by the enrichment limit.
183+
TotalMatches int
184+
// Truncated reports that the tool found more enclosing symbols than the
185+
// limit allowed it to enrich, so Matches is a partial list.
186+
Truncated bool
187+
}
188+
189+
// SearchCodeSymbols runs `cli search_code --mode compact` for pattern and
190+
// returns the graph symbols enclosing each match. Unlike SearchCodeUsage
191+
// (which only counts hits), this identifies *who* references the name - the
192+
// only way to find references to a name the graph has no node for, e.g. a
193+
// symbol's pre-rename name after the tree was reindexed. limit <= 0 leaves
194+
// the tool's own default in place.
195+
func (c *Client) SearchCodeSymbols(ctx context.Context, pattern string, limit int) (*CodeSymbolMatches, error) {
196+
args := []string{"--pattern", pattern, "--mode", "compact"}
197+
if limit > 0 {
198+
args = append(args, "--limit", strconv.Itoa(limit))
199+
}
200+
out, err := c.run(ctx, "search_code", args...)
201+
if err != nil {
202+
return nil, err
203+
}
204+
var result struct {
205+
Results []CodeSymbolMatch `json:"results"`
206+
TotalGrepMatches int `json:"total_grep_matches"`
207+
TotalResults int `json:"total_results"`
208+
}
209+
if err := json.Unmarshal(out, &result); err != nil {
210+
return nil, fmt.Errorf("blastradius/client: parsing search_code output: %w", err)
211+
}
212+
return &CodeSymbolMatches{
213+
Matches: result.Results,
214+
TotalMatches: result.TotalGrepMatches,
215+
Truncated: result.TotalResults > len(result.Results),
216+
}, nil
217+
}
218+
161219
// ArchitectureEntryPoint is one entry in get_architecture's "entry_points"
162220
// aspect - a real, cross-language entry point (main functions, extension
163221
// activate/deactivate hooks, script mains), not just the is_entry_point

blastradius/rename.go

Lines changed: 155 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,11 @@ import (
55
"fmt"
66
"math"
77
"regexp"
8+
"sort"
89
"strings"
910

1011
"github.com/HexmosTech/blastradius/client"
12+
"github.com/HexmosTech/blastradius/score"
1113
"github.com/HexmosTech/blastradius/symbols"
1214
)
1315

@@ -115,33 +117,172 @@ func matchSingleIdentSwap(oldLine, newLine, symName string) *DeclRename {
115117

116118
// renameOldNameSignal runs a best-effort text search for rn.OldName and
117119
// returns a Signal counting how many places in the repo still reference the
118-
// pre-rename name - i.e. call sites/references that are about to break until
119-
// they're migrated to rn.NewName. Weighted 1.5x relative to the plain
120-
// sqrt(refs) used for an ordinary text-reference count elsewhere in this
121-
// package, since this is live, unmigrated breakage risk right now, not just
122-
// historical usage. Unlike the ordinary text-reference count, no "-1 for the
123-
// symbol's own definition" adjustment is applied: the definition itself was
124-
// renamed away, so every remaining match under the old name is an external
125-
// reference.
120+
// pre-rename name after the declaration moved to rn.NewName. Weighted 1.5x
121+
// relative to the plain sqrt(refs) used for an ordinary text-reference count
122+
// elsewhere in this package, since an unmigrated reference is a live risk
123+
// right now, not just historical usage. Unlike the ordinary text-reference
124+
// count, no "-1 for the symbol's own definition" adjustment is applied: the
125+
// definition itself was renamed away, so every remaining match under the old
126+
// name is an external reference.
127+
//
128+
// The wording states only what was measured. search_code is grep-based, so a
129+
// match can equally be a real call, a comment, or a string literal - calling
130+
// these references "about to break" asserted a compile failure that nothing
131+
// here verifies. Points are unchanged; this is phrasing only.
126132
func renameOldNameSignal(ctx context.Context, c *client.Client, rn DeclRename) Signal {
127133
usage, err := c.SearchCodeUsage(ctx, rn.OldName)
128134
if err != nil {
129135
// Distinguish "checked, found nothing" from "couldn't check" - the
130136
// latter must not silently read as 0 points/0 references, which
131-
// would falsely assert the rename is safe when it simply wasn't
132-
// verified.
137+
// would falsely assert the rename is fully migrated when it simply
138+
// wasn't verified.
133139
return Signal{
134-
Name: "Callers of old name (about to break)",
135-
Detail: fmt.Sprintf("could not check references to the pre-rename name %q: %v", rn.OldName, err),
140+
Name: "Still uses the old name",
141+
Detail: fmt.Sprintf("could not check references to the old name %q: %v", rn.OldName, err),
136142
Points: 0,
137143
Category: "graph",
138144
}
139145
}
140146
refs := usage.TotalMatches
141147
return Signal{
142-
Name: "Callers of old name (about to break)",
143-
Detail: fmt.Sprintf("%d reference(s) to the pre-rename name %q still exist and will break until migrated to %q", refs, rn.OldName, rn.NewName),
148+
Name: "Still uses the old name",
149+
Detail: fmt.Sprintf("%d reference(s) to %q remain after the rename to %q", refs, rn.OldName, rn.NewName),
144150
Points: 1.5 * math.Sqrt(float64(refs)),
145151
Category: "graph",
146152
}
147153
}
154+
155+
// oldNameCallerLimit caps how many enclosing symbols search_code is asked to
156+
// resolve for a pre-rename name. Generous enough that a normal rename's call
157+
// sites all fit, bounded so a rename of a very common word (e.g. "get") can't
158+
// turn one hunk into a thousand-node graph.
159+
const oldNameCallerLimit = 60
160+
161+
// preRenameCallerLabels are the node labels that can actually contain a
162+
// reference that breaks. search_code resolves a match to whatever node encloses
163+
// it, which includes documentation and structural nodes (Section, File, Folder,
164+
// Package, ...) - a README mentioning the old name is not a caller, and listing
165+
// it as one would misrepresent the breakage.
166+
var preRenameCallerLabels = map[string]bool{
167+
"Function": true,
168+
"Method": true,
169+
"Class": true,
170+
"Struct": true,
171+
"Variable": true,
172+
"Route": true,
173+
}
174+
175+
// oldNameCallers finds the symbols that still reference rn.OldName - the call
176+
// sites that break until they're migrated to rn.NewName - and returns them as
177+
// depth-1 CallerRefs flagged PreRename.
178+
//
179+
// These cannot come from CALLS fan-in: the graph is indexed against the
180+
// post-rename tree, so the old name has no node and every edge that pointed at
181+
// it was dropped as unresolvable. search_code's compact mode is the way in - it
182+
// resolves each textual hit to the graph node enclosing it, which is exactly
183+
// the caller we want.
184+
//
185+
// Best-effort by the same rule as renameOldNameSignal's error path: a failed
186+
// lookup returns no callers rather than failing the report. It contributes no
187+
// points either way - scoring stays entirely with renameOldNameSignal.
188+
func oldNameCallers(ctx context.Context, c *client.Client, rn DeclRename, selfQN string) []CallerRef {
189+
matches, err := c.SearchCodeSymbols(ctx, rn.OldName, oldNameCallerLimit)
190+
if err != nil {
191+
return nil
192+
}
193+
return preRenameCallersFrom(matches.Matches, selfQN)
194+
}
195+
196+
// preRenameCallersFrom is oldNameCallers' pure half: turn search_code's
197+
// enclosing-symbol matches into a deduplicated, name-sorted caller list.
198+
func preRenameCallersFrom(matches []client.CodeSymbolMatch, selfQN string) []CallerRef {
199+
var callers []CallerRef
200+
seen := make(map[string]bool)
201+
for _, m := range matches {
202+
// The renamed symbol's own declaration is not one of its callers.
203+
// It can still match: a method body referencing its own old name,
204+
// or a stale doc comment above the new declaration.
205+
if m.QualifiedName == "" || m.QualifiedName == selfQN || seen[m.QualifiedName] {
206+
continue
207+
}
208+
if !preRenameCallerLabels[m.Label] {
209+
continue
210+
}
211+
seen[m.QualifiedName] = true
212+
callers = append(callers, CallerRef{
213+
QualifiedName: m.QualifiedName,
214+
Depth: 1,
215+
Weight: 1,
216+
PreRename: true,
217+
})
218+
}
219+
sort.Slice(callers, func(i, j int) bool { return callers[i].QualifiedName < callers[j].QualifiedName })
220+
return callers
221+
}
222+
223+
// expandPreRenameCallers adds the transitive fan-in of direct pre-rename
224+
// callers. Unlike the old name itself, those callers are ordinary present-day
225+
// graph nodes, so score.FanIn can walk them - which is what gives a rename's
226+
// sunburst/flamegraph the same depth structure as any other symbol's instead of
227+
// a single flat ring.
228+
//
229+
// Each discovered caller comes back one hop further from the renamed symbol
230+
// than it is from the direct caller, reached *through* that direct caller, so
231+
// its Depth is shifted by 1 and its Path prefixed accordingly. Anything already
232+
// present as a direct pre-rename caller is left alone - the shallower depth is
233+
// the truthful one.
234+
func expandPreRenameCallers(ctx context.Context, q score.GraphQuerier, direct []CallerRef, cfg score.Config, selfQN string) []CallerRef {
235+
if len(direct) == 0 {
236+
return nil
237+
}
238+
directQN := make([]string, 0, len(direct))
239+
seen := make(map[string]bool, len(direct))
240+
for _, c := range direct {
241+
directQN = append(directQN, c.QualifiedName)
242+
seen[c.QualifiedName] = true
243+
}
244+
// One fewer hop than a normal walk: the direct pre-rename callers are
245+
// already one hop out from the renamed symbol, so the budget left for
246+
// their own callers is MaxDepth-1.
247+
expandCfg := cfg
248+
expandCfg.MaxDepth = cfg.MaxDepth - 1
249+
if expandCfg.MaxDepth < 1 {
250+
return nil
251+
}
252+
scores, err := score.FanIn(ctx, q, directQN, expandCfg)
253+
if err != nil {
254+
return nil // best-effort: the direct callers alone are still worth showing
255+
}
256+
257+
best := make(map[string]CallerRef)
258+
for viaQN, ss := range scores {
259+
for _, cc := range ss.Callers {
260+
qn := cc.QualifiedName
261+
if qn == "" || qn == selfQN || seen[qn] {
262+
continue
263+
}
264+
depth := cc.Depth + 1
265+
if existing, ok := best[qn]; ok && existing.Depth <= depth {
266+
continue
267+
}
268+
best[qn] = CallerRef{
269+
QualifiedName: qn,
270+
Depth: depth,
271+
Weight: cc.Weight * cfg.Decay,
272+
Path: append([]string{viaQN}, cc.Path...),
273+
PreRename: true,
274+
}
275+
}
276+
}
277+
expanded := make([]CallerRef, 0, len(best))
278+
for _, c := range best {
279+
expanded = append(expanded, c)
280+
}
281+
sort.Slice(expanded, func(i, j int) bool {
282+
if expanded[i].Depth != expanded[j].Depth {
283+
return expanded[i].Depth < expanded[j].Depth
284+
}
285+
return expanded[i].QualifiedName < expanded[j].QualifiedName
286+
})
287+
return expanded
288+
}

0 commit comments

Comments
 (0)