Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 47 additions & 2 deletions blast_radius.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ type blastRadiusDiff struct {
Only []string `json:"only,omitempty"`
Exclude []string `json:"exclude,omitempty"`
ChangedFilesTotal int `json:"changed_files_total"`
DiffTotals *scanner.DiffTotals `json:"diff_totals,omitempty"`
}

type blastRadiusDeps struct {
Expand Down Expand Up @@ -451,6 +452,17 @@ func buildBlastRadiusBundle(absRoot, ref string, limits blastRadiusLimits) (blas
return blastRadiusBundle{}, err
}
changedFiles := scanner.FilterToChangedWithInfo(allFiles, diffInfo)
// Rank by churn before any cap so the cut lands on the smallest edits.
// Scan order was path order, which on a 52-file refactor dropped the
// highest-churn file and the app entry point while keeping tiny files
// that sorted earlier (#149). Path breaks ties so output stays stable.
sort.SliceStable(changedFiles, func(i, j int) bool {
ci, cj := changedFiles[i].Added+changedFiles[i].Removed, changedFiles[j].Added+changedFiles[j].Removed
if ci != cj {
return ci > cj
}
return changedFiles[i].Path < changedFiles[j].Path
})
diffTotal := len(changedFiles)

changedSet := make(map[string]bool, diffTotal)
Expand Down Expand Up @@ -516,7 +528,10 @@ func buildBlastRadiusBundle(absRoot, ref string, limits blastRadiusLimits) (blas
// Carry scan provenance into the deps project so a fail-closed scan
// never reads as an empty, complete dependency graph.
depsProject.Coverage = scanner.CoverageFromSources(fg.Coverage.Sources)
depsCapped = capBlastRadiusDepsProject(depsProject, limits.MaxChangedFiles)
// Show the same files the diff panel shows. A prefix of scan order was
// a different subset from the churn-ranked diff, so the two panels
// described different changes.
depsCapped = capBlastRadiusDepsProject(filterDepsToFiles(depsProject, diffCapped.Files), limits.MaxChangedFiles)

// Analyze the FULL changed set; capping only limits what is displayed,
// it must not shrink blast-radius analysis or the summary stats.
Expand Down Expand Up @@ -560,6 +575,7 @@ func buildBlastRadiusBundle(absRoot, ref string, limits blastRadiusLimits) (blas
Only: diffCapped.Only,
Exclude: diffCapped.Exclude,
ChangedFilesTotal: diffTotal,
DiffTotals: diffCapped.DiffTotals,
},
Deps: blastRadiusDeps{
Root: depsCapped.Root,
Expand All @@ -586,6 +602,14 @@ func capBlastRadiusProject(project scanner.Project, max int) scanner.Project {
if max >= len(project.Files) {
return project
}
// Keep the whole diff's counts so the rendered header states the total,
// not the subset it happens to show.
totals := &scanner.DiffTotals{Files: len(project.Files)}
for _, f := range project.Files {
totals.Added += f.Added
totals.Removed += f.Removed
}
project.DiffTotals = totals
project.Files = append([]scanner.FileInfo(nil), project.Files[:max]...)
// Impact is an independent, usage-sorted list keyed by basename; filter it to
// the files actually shown rather than slicing it by the changed-file cap,
Expand Down Expand Up @@ -617,6 +641,23 @@ func filterImpactToShownFiles(impact []scanner.ImpactInfo, files []scanner.FileI
return filtered
}

// filterDepsToFiles keeps the analyses for exactly the given changed files,
// in that order, so the deps panel covers the files the diff panel shows.
func filterDepsToFiles(project scanner.DepsProject, files []scanner.FileInfo) scanner.DepsProject {
byPath := make(map[string]scanner.FileAnalysis, len(project.Files))
for _, a := range project.Files {
byPath[filepath.ToSlash(a.Path)] = a
}
kept := make([]scanner.FileAnalysis, 0, len(files))
for _, f := range files {
if a, ok := byPath[filepath.ToSlash(f.Path)]; ok {
kept = append(kept, a)
}
}
project.Files = kept
return project
}

func capBlastRadiusDepsProject(project scanner.DepsProject, max int) scanner.DepsProject {
if max >= len(project.Files) {
return project
Expand Down Expand Up @@ -1074,7 +1115,11 @@ func renderBlastRadiusMarkdown(bundle blastRadiusBundle) string {
_, _ = fmt.Fprintf(&summary, "- Root: `%s`\n", bundle.Root)
_, _ = fmt.Fprintf(&summary, "- Base ref: `%s`\n\n", bundle.Ref)
summary.WriteString("## Summary\n\n")
_, _ = fmt.Fprintf(&summary, "- Changed files: %d shown of %d\n", bundle.Summary.ChangedFiles, bundle.Summary.ChangedFilesTotal)
if bundle.Summary.ChangedFiles < bundle.Summary.ChangedFilesTotal {
_, _ = fmt.Fprintf(&summary, "- Changed files: %d shown of %d (ranked by churn; raise --max-changed-files)\n", bundle.Summary.ChangedFiles, bundle.Summary.ChangedFilesTotal)
} else {
_, _ = fmt.Fprintf(&summary, "- Changed files: %d shown of %d\n", bundle.Summary.ChangedFiles, bundle.Summary.ChangedFilesTotal)
}
_, _ = fmt.Fprintf(&summary, "- Changed files with direct dependents: %d\n", bundle.Summary.FilesWithDependents)
_, _ = fmt.Fprintf(&summary, "- Affected files outside diff: %d shown of %d\n", bundle.Summary.ImpactedOutsideDiffShown, bundle.Summary.ImpactedOutsideDiffTotal)
_, _ = fmt.Fprintf(&summary, "- Dependency context outside diff: %d shown of %d\n", bundle.Summary.DependencyContextOutsideDiffShown, bundle.Summary.DependencyContextOutsideDiffTotal)
Expand Down
64 changes: 64 additions & 0 deletions blast_radius_cap_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package main

import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
)

// #149: the changed-file cap took the first files in scan order and the diff
// panel header reported that subset's counts as the whole change. The cap must
// keep the highest-churn files, the header must state the full totals, and
// the deps panel must cover the same files as the diff panel.
func TestBlastRadiusCapRanksByChurnAndStatesTotals(t *testing.T) {
requireBlastRadiusTools(t)
root := makeBlastRadiusHubRepo(t)

// hub.go sorts after aaa/aaa.go; give it the larger churn so only ranking
// can put it in the single shown slot.
hub := "package hub\n\nfunc Helper() int {\n\treturn 2\n}\n\nfunc A() {}\n\nfunc B() {}\n\nfunc C() {}\n"
if err := os.WriteFile(filepath.Join(root, "pkg", "hub", "hub.go"), []byte(hub), 0o644); err != nil {
t.Fatal(err)
}

out, stderr, err := runCodemapWithInput("", "blast-radius", "--json", "--ref", "HEAD",
"--max-changed-files", "1", root)
if err != nil {
t.Fatalf("blast-radius failed: %v\nstderr=%s", err, stderr)
}
var bundle blastRadiusBundle
if err := json.Unmarshal([]byte(out), &bundle); err != nil {
t.Fatalf("bad JSON: %v\n%s", err, out)
}

if len(bundle.Diff.Files) != 1 || !strings.Contains(bundle.Diff.Files[0].Path, "hub") {
t.Fatalf("shown diff files = %+v, want only the high-churn hub.go", bundle.Diff.Files)
}
if bundle.Diff.DiffTotals == nil || bundle.Diff.DiffTotals.Files != 2 {
t.Fatalf("diff_totals = %+v, want files=2 for the whole diff", bundle.Diff.DiffTotals)
}
wantAdded := 0
for _, f := range bundle.Diff.Files {
wantAdded += f.Added
}
if bundle.Diff.DiffTotals.Added <= wantAdded {
t.Fatalf("diff_totals.added = %d, want more than the shown subset's %d", bundle.Diff.DiffTotals.Added, wantAdded)
}
for _, want := range []string{"Changed: 2 files", "1 shown, ranked by churn (--max-changed-files)"} {
if !strings.Contains(bundle.Rendered.Diff, want) {
t.Fatalf("rendered diff header lacks %q:\n%s", want, bundle.Rendered.Diff)
}
}
if len(bundle.Deps.Files) != 1 || !strings.Contains(bundle.Deps.Files[0].Path, "hub") {
t.Fatalf("deps files = %+v, want the same single file the diff panel shows", bundle.Deps.Files)
}
md, stderr, err := runCodemapWithInput("", "blast-radius", "--ref", "HEAD", "--max-changed-files", "1", root)
if err != nil {
t.Fatalf("blast-radius markdown failed: %v\nstderr=%s", err, stderr)
}
if !strings.Contains(md, "1 shown of 2 (ranked by churn; raise --max-changed-files)") {
t.Fatalf("markdown summary does not label the cap:\n%s", md)
}
}
23 changes: 15 additions & 8 deletions render/tree.go
Original file line number Diff line number Diff line change
Expand Up @@ -193,16 +193,13 @@ func Tree(w io.Writer, project scanner.Project) {
innerWidth = len(extLine) + 4
}

// Title in top border line (like rich panel)
titleLine := fmt.Sprintf(" %s ", projectName)
padding := innerWidth - len(titleLine)
leftPad := padding / 2
rightPad := padding - leftPad
fmt.Fprintf(w, "╭%s%s%s╮\n", strings.Repeat("─", leftPad), titleLine, strings.Repeat("─", rightPad))

// Stats line - different for diff mode
var statsLine string
if isDiffMode {
if t := project.DiffTotals; isDiffMode && t != nil && t.Files > totalFiles {
// The panel holds a subset; the header must still state the whole diff.
statsLine = fmt.Sprintf("Changed: %d files | +%d -%d lines vs %s | %d shown, ranked by churn (--max-changed-files)",
t.Files, t.Added, t.Removed, project.DiffRef, totalFiles)
} else if isDiffMode {
if totalRemoved > 0 {
statsLine = fmt.Sprintf("Changed: %d files | +%d -%d lines vs %s", totalFiles, totalAdded, totalRemoved, project.DiffRef)
} else {
Expand All @@ -211,6 +208,16 @@ func Tree(w io.Writer, project scanner.Project) {
} else {
statsLine = fmt.Sprintf("Files: %d | Size: %s", totalFiles, formatSize(totalSize))
}
if len(statsLine)+4 > innerWidth {
innerWidth = len(statsLine) + 4
}

// Title in top border line (like rich panel)
titleLine := fmt.Sprintf(" %s ", projectName)
padding := innerWidth - len(titleLine)
leftPad := padding / 2
rightPad := padding - leftPad
fmt.Fprintf(w, "╭%s%s%s╮\n", strings.Repeat("─", leftPad), titleLine, strings.Repeat("─", rightPad))
fmt.Fprintf(w, "│ %-*s │\n", innerWidth-2, statsLine)

// Extensions line
Expand Down
10 changes: 10 additions & 0 deletions scanner/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,16 @@ type Project struct {
Depth int `json:"depth,omitempty"` // Max tree depth (0 = unlimited)
Only []string `json:"only,omitempty"` // Extension filter (e.g., ["swift", "go"])
Exclude []string `json:"exclude,omitempty"` // Exclusion patterns (e.g., [".xcassets", "Fonts"])
// DiffTotals describes the whole diff when Files holds only a shown
// subset, so a renderer never presents the subset's counts as the total.
DiffTotals *DiffTotals `json:"diff_totals,omitempty"`
}

// DiffTotals are the counts over every changed file, shown or not.
type DiffTotals struct {
Files int `json:"files"`
Added int `json:"added"`
Removed int `json:"removed"`
}

// FileAnalysis holds extracted info about a single file for deps mode.
Expand Down
Loading