Skip to content
Open
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
78 changes: 58 additions & 20 deletions blast_radius.go
Original file line number Diff line number Diff line change
Expand Up @@ -140,17 +140,18 @@ type blastRadiusRendered struct {
}

type blastRadiusBundle struct {
Root string `json:"root"`
Ref string `json:"ref"`
Summary blastRadiusSummary `json:"summary"`
Diff blastRadiusDiff `json:"diff"`
Deps blastRadiusDeps `json:"deps"`
Importers []blastRadiusImporters `json:"importers"`
Limits blastRadiusLimits `json:"limits"`
ImpactedOutsideDiff []blastRadiusRelation `json:"impacted_outside_diff"`
DependencyContextOutsideDiff []blastRadiusRelation `json:"dependency_context_outside_diff"`
Snippets []blastRadiusSnippet `json:"snippets"`
Rendered blastRadiusRendered `json:"rendered"`
Root string `json:"root"`
Ref string `json:"ref"`
Summary blastRadiusSummary `json:"summary"`
Diff blastRadiusDiff `json:"diff"`
Deps blastRadiusDeps `json:"deps"`
Importers []blastRadiusImporters `json:"importers"`
Limits blastRadiusLimits `json:"limits"`
ImpactedOutsideDiff []blastRadiusRelation `json:"impacted_outside_diff"`
DependencyContextOutsideDiff []blastRadiusRelation `json:"dependency_context_outside_diff"`
AffectedModules []blastRadiusModuleImpact `json:"affected_modules,omitempty"`
Snippets []blastRadiusSnippet `json:"snippets"`
Rendered blastRadiusRendered `json:"rendered"`
}

type blastChangedMeta struct {
Expand Down Expand Up @@ -537,6 +538,7 @@ func buildBlastRadiusBundle(absRoot, ref string, limits blastRadiusLimits) (blas
}

summary := buildBlastRadiusSummary(diffCapped.Files, diffTotal, impacted, rawImpacted, ctxRelations, rawContext, allReports)
affectedModules := collectTopologyImpacts(absRoot, changedFiles)

bundle := blastRadiusBundle{
Root: absRoot,
Expand Down Expand Up @@ -568,6 +570,7 @@ func buildBlastRadiusBundle(absRoot, ref string, limits blastRadiusLimits) (blas
Limits: limits,
ImpactedOutsideDiff: impacted,
DependencyContextOutsideDiff: ctxRelations,
AffectedModules: affectedModules,
Snippets: snippets,
}
bundle.Rendered = buildBlastRadiusRendered(diffCapped, depsCapped, shownReports, diffTotal, limits)
Expand Down Expand Up @@ -1057,18 +1060,18 @@ func renderBlastRadiusMarkdown(bundle blastRadiusBundle) string {

var summary strings.Builder
summary.WriteString("# Codemap Blast Radius\n\n")
summary.WriteString(fmt.Sprintf("- Root: `%s`\n", bundle.Root))
summary.WriteString(fmt.Sprintf("- Base ref: `%s`\n\n", bundle.Ref))
_, _ = fmt.Fprintf(&summary, "- Root: `%s`\n", bundle.Root)
_, _ = fmt.Fprintf(&summary, "- Base ref: `%s`\n\n", bundle.Ref)
summary.WriteString("## Summary\n\n")
summary.WriteString(fmt.Sprintf("- Changed files: %d shown of %d\n", bundle.Summary.ChangedFiles, bundle.Summary.ChangedFilesTotal))
summary.WriteString(fmt.Sprintf("- Changed files with direct dependents: %d\n", bundle.Summary.FilesWithDependents))
summary.WriteString(fmt.Sprintf("- Affected files outside diff: %d shown of %d\n", bundle.Summary.ImpactedOutsideDiffShown, bundle.Summary.ImpactedOutsideDiffTotal))
summary.WriteString(fmt.Sprintf("- Dependency context outside diff: %d shown of %d\n", bundle.Summary.DependencyContextOutsideDiffShown, bundle.Summary.DependencyContextOutsideDiffTotal))
_, _ = 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)
if bundle.Summary.HighestBlastRadius != nil {
summary.WriteString(fmt.Sprintf("- Highest blast radius: `%s` (%d direct dependents)\n", bundle.Summary.HighestBlastRadius.File, bundle.Summary.HighestBlastRadius.ImporterCount))
_, _ = fmt.Fprintf(&summary, "- Highest blast radius: `%s` (%d direct dependents)\n", bundle.Summary.HighestBlastRadius.File, bundle.Summary.HighestBlastRadius.ImporterCount)
}
summary.WriteString(fmt.Sprintf("- Output budgets: total %d chars, diff %d, deps %d, importers %d\n", bundle.Limits.MaxTotalChars, bundle.Limits.MaxDiffChars, bundle.Limits.MaxDepsChars, bundle.Limits.MaxImportersChars))
summary.WriteString(fmt.Sprintf("- Snippet limits: %d total, %d per changed file, %d chars max\n\n", bundle.Limits.MaxSnippets, bundle.Limits.MaxSnippetsPerChanged, bundle.Limits.MaxSnippetChars))
_, _ = fmt.Fprintf(&summary, "- Output budgets: total %d chars, diff %d, deps %d, importers %d\n", bundle.Limits.MaxTotalChars, bundle.Limits.MaxDiffChars, bundle.Limits.MaxDepsChars, bundle.Limits.MaxImportersChars)
_, _ = fmt.Fprintf(&summary, "- Snippet limits: %d total, %d per changed file, %d chars max\n\n", bundle.Limits.MaxSnippets, bundle.Limits.MaxSnippetsPerChanged, bundle.Limits.MaxSnippetChars)
if !builder.Append(summary.String(), "summary") {
return builder.String()
}
Expand All @@ -1095,6 +1098,25 @@ func renderBlastRadiusMarkdown(bundle blastRadiusBundle) string {
}
}

if len(bundle.AffectedModules) > 0 {
var section strings.Builder
section.WriteString("## Affected Modules\n\n")
for _, item := range bundle.AffectedModules {
_, _ = fmt.Fprintf(&section, "- `%s` (`%s`): %s", item.Name, item.ID, item.Relation)
if item.Via != "" {
_, _ = fmt.Fprintf(&section, " via `%s`", item.Via)
}
if item.Dependents > 0 {
_, _ = fmt.Fprintf(&section, " [%d dependent modules]", item.Dependents)
}
section.WriteString("\n")
}
section.WriteString("\n")
if !builder.Append(section.String(), "affected modules") {
return builder.String()
}
}

if len(bundle.DependencyContextOutsideDiff) > 0 {
var section strings.Builder
section.WriteString("## Dependency Context Outside Diff\n\n")
Expand Down Expand Up @@ -1190,6 +1212,22 @@ func renderBlastRadiusText(bundle blastRadiusBundle) string {
}
}

if len(bundle.AffectedModules) > 0 {
var section strings.Builder
section.WriteString("[affected_modules]\n")
for _, item := range bundle.AffectedModules {
_, _ = fmt.Fprintf(&section, "%s (%s): %s", item.Name, item.ID, item.Relation)
if item.Via != "" {
_, _ = fmt.Fprintf(&section, " <= %s", item.Via)
}
section.WriteString("\n")
}
section.WriteString("\n")
if !builder.Append(section.String(), "affected modules") {
return builder.String()
}
}

if len(bundle.DependencyContextOutsideDiff) > 0 {
var section strings.Builder
section.WriteString("[dependency_context_outside_diff]\n")
Expand Down
105 changes: 105 additions & 0 deletions blast_radius_topology.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
package main

import (
"context"
"path/filepath"
"sort"

"codemap/scanner"
"codemap/topology"
)

type blastRadiusModuleImpact struct {
ID topology.ID `json:"id"`
Name string `json:"name"`
Relation string `json:"relation"`
Via topology.ID `json:"via,omitempty"`
Dependents int `json:"dependents,omitempty"`
}

var buildBlastTopology = topology.BuildGraph

func collectTopologyImpacts(root string, changed []scanner.FileInfo) []blastRadiusModuleImpact {
if len(changed) == 0 {
return nil
}
graph, _, err := buildBlastTopology(context.Background(), root)
if err != nil || graph == nil || graph.Coverage.Status == topology.CoverageUnavailable {
return nil
}
return buildTopologyImpacts(graph, changed)
}

func buildTopologyImpacts(graph *topology.Graph, changed []scanner.FileInfo) []blastRadiusModuleImpact {
if graph == nil {
return nil
}
owners := make(map[topology.ID]bool)
for _, file := range changed {
candidates := graph.OwnersForFile(filepath.Clean(file.Path))
if len(candidates) == 1 {
owners[candidates[0]] = true
}
}

impacts := make(map[topology.ID]blastRadiusModuleImpact)
for ownerID := range owners {
node, ok := graph.Nodes[ownerID]
if !ok {
continue
}
impacts[ownerID] = blastRadiusModuleImpact{
ID: ownerID,
Name: node.Name,
Relation: "owns-changed-file",
Dependents: topologyDependentCount(graph, ownerID),
}
for _, edge := range graph.Dependents[ownerID] {
if edge.Kind != topology.EdgeDependency || owners[edge.From] {
continue
}
dependent, ok := graph.Nodes[edge.From]
if !ok {
continue
}
impacts[dependent.ID] = blastRadiusModuleImpact{
ID: dependent.ID,
Name: dependent.Name,
Relation: "depends-on-changed-module",
Via: ownerID,
Dependents: topologyDependentCount(graph, dependent.ID),
}
}
}

result := make([]blastRadiusModuleImpact, 0, len(impacts))
for _, impact := range impacts {
result = append(result, impact)
}
sort.Slice(result, func(i, j int) bool {
leftRank := moduleImpactRank(result[i].Relation)
rightRank := moduleImpactRank(result[j].Relation)
if leftRank != rightRank {
return leftRank < rightRank
}
return result[i].ID < result[j].ID
})
return result
}

func topologyDependentCount(graph *topology.Graph, id topology.ID) int {
dependents := make(map[topology.ID]bool)
for _, edge := range graph.Dependents[id] {
if edge.Kind == topology.EdgeDependency {
dependents[edge.From] = true
}
}
return len(dependents)
}

func moduleImpactRank(relation string) int {
if relation == "owns-changed-file" {
return 0
}
return 1
}
109 changes: 109 additions & 0 deletions blast_radius_topology_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
package main

import (
"context"
"strings"
"testing"

"codemap/scanner"
"codemap/topology"
)

func TestBuildTopologyImpactsKeepsModulesSeparateFromFiles(t *testing.T) {
core := topology.Node{
ID: topology.ID("jvm:settings.gradle.kts:core"),
Name: "core",
Manifest: "settings.gradle.kts",
Root: "core",
Provider: "jvm",
}
app := topology.Node{
ID: topology.ID("jvm:settings.gradle.kts:app"),
Name: "app",
Manifest: "settings.gradle.kts",
Root: "app",
Provider: "jvm",
}
graph := &topology.Graph{
Nodes: map[topology.ID]topology.Node{core.ID: core, app.ID: app},
Dependencies: map[topology.ID][]topology.Edge{
app.ID: {{From: app.ID, To: core.ID, Kind: topology.EdgeDependency}},
},
Dependents: map[topology.ID][]topology.Edge{
core.ID: {{From: app.ID, To: core.ID, Kind: topology.EdgeDependency}},
},
Members: map[topology.ID][]string{
core.ID: {"core/src/Core.kt"},
app.ID: {"app/src/Main.kt", "app/src/Other.kt"},
},
Owners: map[string][]topology.ID{
"core/src/Core.kt": {core.ID},
"app/src/Main.kt": {app.ID},
"app/src/Other.kt": {app.ID},
},
Coverage: topology.Coverage{Status: topology.CoverageComplete},
}
changed := []scanner.FileInfo{{Path: "core/src/Core.kt"}}

got := buildTopologyImpacts(graph, changed)
if len(got) != 2 {
t.Fatalf("module impacts = %#v, want owner and dependent", got)
}
if got[0].ID != core.ID || got[0].Relation != "owns-changed-file" {
t.Fatalf("owner impact = %#v", got[0])
}
if got[1].ID != app.ID || got[1].Relation != "depends-on-changed-module" {
t.Fatalf("dependent impact = %#v", got[1])
}
for _, impact := range got {
if strings.Contains(impact.Name, ".kt") {
t.Fatalf("module impact contains an expanded member file: %#v", impact)
}
}
}

func TestCollectTopologyImpactsSkipsEmptyDiff(t *testing.T) {
original := buildBlastTopology
called := false
buildBlastTopology = func(context.Context, string) (*topology.Graph, topology.CacheIdentity, error) {
called = true
return nil, topology.CacheIdentity{}, nil
}
t.Cleanup(func() { buildBlastTopology = original })

if got := collectTopologyImpacts(t.TempDir(), nil); got != nil {
t.Fatalf("empty diff impacts = %#v, want nil", got)
}
if called {
t.Fatal("empty diff built topology")
}
}

func TestRenderBlastRadiusIncludesSeparateModuleSection(t *testing.T) {
bundle := blastRadiusBundle{
Root: "/repo",
Ref: "main",
Limits: defaultBlastRadiusLimits(),
Summary: blastRadiusSummary{
ChangedFiles: 1,
ChangedFilesTotal: 1,
},
AffectedModules: []blastRadiusModuleImpact{{
ID: topology.ID("jvm:settings.gradle.kts:app"),
Name: "app",
Relation: "depends-on-changed-module",
Via: topology.ID("jvm:settings.gradle.kts:core"),
}},
}

markdown := renderBlastRadiusMarkdown(bundle)
if !strings.Contains(markdown, "## Affected Modules") ||
!strings.Contains(markdown, "`app`") ||
strings.Contains(markdown, "app/src/") {
t.Fatalf("unexpected markdown module section:\n%s", markdown)
}
text := renderBlastRadiusText(bundle)
if !strings.Contains(text, "[affected_modules]") || !strings.Contains(text, "app") {
t.Fatalf("unexpected text module section:\n%s", text)
}
}
32 changes: 32 additions & 0 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,10 @@ func main() {
watchMode := flag.Bool("watch", false, "Live file watcher daemon (experimental)")
stdinMode := flag.Bool("stdin", false, "Read file manifest from stdin (use with --deps)")
importersMode := flag.String("importers", "", "Check file impact: who imports it, is it a hub?")
topologyMode := flag.Bool("topology", false, "Show project/module dependency topology")
moduleMode := flag.String("module", "", "Show context for one exact module ID or unique name")
moduleFileMode := flag.String("module-file", "", "Show context for the module owning a repository-relative file")
ecosystemMode := flag.String("ecosystem", "", "Filter topology to one provider/ecosystem")
helpMode := flag.Bool("help", false, "Show help")
flag.BoolVar(helpMode, "h", false, "Show help (shorthand)")
// Short flag aliases
Expand Down Expand Up @@ -253,6 +257,10 @@ func main() {
fmt.Println(" --json Output machine-readable JSON")
fmt.Println(" --debug Show scanner and path diagnostics")
fmt.Println(" --watch Run the live file watcher daemon")
fmt.Println(" --topology Project/module dependency topology")
fmt.Println(" --module <id|name> Context for one module ID or unique name")
fmt.Println(" --module-file <file> Context for the module owning a file")
fmt.Println(" --ecosystem <provider> Filter topology by provider")
fmt.Println()
fmt.Println("Examples:")
fmt.Println(" codemap . # Basic tree view")
Expand All @@ -265,6 +273,8 @@ func main() {
fmt.Println(" codemap --only swift . # Just Swift files")
fmt.Println(" codemap --exclude .xcassets,Fonts,.png # Hide assets")
fmt.Println(" codemap --importers scanner/types.go # Check file impact")
fmt.Println(" codemap --topology /path/to/repo # Project/module topology")
fmt.Println(" codemap --module app /path/to/repo # Context for module app")
fmt.Println(" echo '{...}' | codemap --deps --stdin # Deps from file manifest")
fmt.Println()
fmt.Println("Remote repos (clones temporarily):")
Expand Down Expand Up @@ -405,6 +415,28 @@ func main() {
return
}

if *moduleMode != "" && *moduleFileMode != "" {
fmt.Fprintln(os.Stderr, "Error: specify either --module or --module-file, not both")
os.Exit(2)
}
requestedTopology := *topologyMode || *moduleMode != "" || *moduleFileMode != ""
if *ecosystemMode != "" && !requestedTopology {
fmt.Fprintln(os.Stderr, "Error: --ecosystem requires --topology, --module, or --module-file")
os.Exit(2)
}
if requestedTopology {
topologyRoot, err := canonicalTopologyRoot(absRoot)
if err != nil {
fmt.Fprintf(os.Stderr, "Error selecting topology project: %v\n", err)
os.Exit(1)
}
if err := runTopologyMode(context.Background(), topologyRoot, *moduleMode, *moduleFileMode, *ecosystemMode, *jsonMode, os.Stdout); err != nil {
fmt.Fprintf(os.Stderr, "Error building topology: %v\n", err)
os.Exit(1)
}
return
}

// Get changed files if --diff is specified
var diffInfo *scanner.DiffInfo
if *diffMode {
Expand Down
Loading
Loading