From dbf8d83ab3bb7c68a2ef25dc827519f4444b48f4 Mon Sep 17 00:00:00 2001 From: Jordan Coin Jackson Date: Wed, 8 Apr 2026 15:01:17 -0400 Subject: [PATCH 1/2] blast-radius subcommand --- README.md | 7 +- blast_radius.go | 1288 +++++++++++++++++++++++++++++++ blast_radius_test.go | 129 ++++ main.go | 75 +- main_test.go | 1 + scripts/codemap-blast-radius.sh | 700 ----------------- 6 files changed, 1432 insertions(+), 768 deletions(-) create mode 100644 blast_radius.go create mode 100644 blast_radius_test.go delete mode 100755 scripts/codemap-blast-radius.sh diff --git a/README.md b/README.md index 2546f77..ad96450 100644 --- a/README.md +++ b/README.md @@ -226,11 +226,12 @@ codemap --json --deps --diff --ref main . codemap --json --importers path/to/file . ``` -For a reusable wrapper that emits either Markdown or a single JSON object: +For a reusable built-in command that emits either Markdown, text, or a single JSON object: ```bash -bash scripts/codemap-blast-radius.sh --markdown --ref main . -bash scripts/codemap-blast-radius.sh --json --ref main . +codemap blast-radius --ref main . +codemap blast-radius --json --ref main . +codemap blast-radius --text --ref main . ``` ## Claude Integration diff --git a/blast_radius.go b/blast_radius.go new file mode 100644 index 0000000..faefca7 --- /dev/null +++ b/blast_radius.go @@ -0,0 +1,1288 @@ +package main + +import ( + "bytes" + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "os" + "path" + "path/filepath" + "regexp" + "sort" + "strconv" + "strings" + "unicode/utf8" + + "codemap/config" + "codemap/render" + "codemap/scanner" +) + +type blastRadiusFormat string + +const ( + blastRadiusFormatMarkdown blastRadiusFormat = "markdown" + blastRadiusFormatText blastRadiusFormat = "text" + blastRadiusFormatJSON blastRadiusFormat = "json" +) + +var ansiSequencePattern = regexp.MustCompile(`\x1b\[[0-9;]*[A-Za-z]`) + +type blastRadiusLimits struct { + MaxTotalChars int `json:"max_total_chars"` + MaxChangedFiles int `json:"max_changed_files"` + MaxAffected int `json:"max_affected"` + MaxContext int `json:"max_context"` + MaxSnippets int `json:"max_snippets"` + MaxSnippetsPerChanged int `json:"max_snippets_per_changed"` + SnippetRadius int `json:"snippet_radius"` + MaxSnippetChars int `json:"max_snippet_chars"` + MaxDiffChars int `json:"max_diff_chars"` + MaxDepsChars int `json:"max_deps_chars"` + MaxImportersChars int `json:"max_importers_chars"` + MaxImporterFiles int `json:"max_importer_files"` + MaxImportersPerFile int `json:"max_importers_per_file"` +} + +type blastRadiusDiff struct { + Root string `json:"root"` + Name string `json:"name,omitempty"` + Mode string `json:"mode"` + Files []scanner.FileInfo `json:"files"` + DiffRef string `json:"diff_ref,omitempty"` + Impact []scanner.ImpactInfo `json:"impact,omitempty"` + Depth int `json:"depth,omitempty"` + Only []string `json:"only,omitempty"` + Exclude []string `json:"exclude,omitempty"` + ChangedFilesTotal int `json:"changed_files_total"` +} + +type blastRadiusDeps struct { + Root string `json:"root"` + Mode string `json:"mode"` + Files []scanner.FileAnalysis `json:"files"` + ExternalDeps map[string][]string `json:"external_deps"` + DiffRef string `json:"diff_ref,omitempty"` + ChangedFilesTotal int `json:"changed_files_total"` +} + +type blastRadiusImporters struct { + Root string `json:"root"` + Mode string `json:"mode"` + File string `json:"file"` + Importers []string `json:"importers"` + Imports []string `json:"imports,omitempty"` + HubImports []string `json:"hub_imports,omitempty"` + ImporterCount int `json:"importer_count"` + IsHub bool `json:"is_hub"` + ImportersTotal int `json:"importers_total"` + ImportsTotal int `json:"imports_total"` + HubImportsTotal int `json:"hub_imports_total"` +} + +type blastRadiusHighest struct { + File string `json:"file"` + ImporterCount int `json:"importer_count"` +} + +type blastRadiusSummary struct { + ChangedFiles int `json:"changed_files"` + ChangedFilesTotal int `json:"changed_files_total"` + FilesWithDependents int `json:"files_with_dependents"` + ImpactedOutsideDiffTotal int `json:"impacted_outside_diff_total"` + ImpactedOutsideDiffShown int `json:"impacted_outside_diff_shown"` + DependencyContextOutsideDiffTotal int `json:"dependency_context_outside_diff_total"` + DependencyContextOutsideDiffShown int `json:"dependency_context_outside_diff_shown"` + MaxDirectDependents int `json:"max_direct_dependents"` + HighestBlastRadius *blastRadiusHighest `json:"highest_blast_radius,omitempty"` +} + +type blastRadiusRelation struct { + Path string `json:"path"` + Via string `json:"via"` + Relation string `json:"relation"` + ViaIsHub bool `json:"via_is_hub,omitempty"` + ViaImporterCount int `json:"via_importer_count,omitempty"` + IsHub bool `json:"is_hub,omitempty"` +} + +type blastRadiusSnippet struct { + Category string `json:"category"` + Path string `json:"path"` + Via string `json:"via"` + Reason string `json:"reason"` + MatchedTerm string `json:"matched_term"` + MatchKind string `json:"match_kind"` + Language string `json:"language"` + Excerpt string `json:"excerpt"` +} + +type blastRadiusRenderedImporter struct { + File string `json:"file"` + Text string `json:"text"` +} + +type blastRadiusRendered struct { + Diff string `json:"diff"` + Deps string `json:"deps"` + Importers []blastRadiusRenderedImporter `json:"importers"` +} + +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"` +} + +type blastChangedMeta struct { + Functions []string + Stem string + Dir string + DirBase string + PathNoExt string +} + +type blastOutputBuilder struct { + total int + remaining int + builder strings.Builder +} + +func runBlastRadiusSubcommand(args []string) { + limits := defaultBlastRadiusLimits() + fs := flag.NewFlagSet("blast-radius", flag.ContinueOnError) + fs.SetOutput(os.Stderr) + + var jsonMode bool + var markdownMode bool + var textMode bool + var help bool + ref := fs.String("ref", "main", "Branch/ref to compare against") + fs.BoolVar(&jsonMode, "json", false, "Emit a single JSON object") + fs.BoolVar(&markdownMode, "markdown", false, "Emit Markdown output (default)") + fs.BoolVar(&markdownMode, "md", false, "Emit Markdown output (default)") + fs.BoolVar(&textMode, "text", false, "Emit plain text output") + fs.BoolVar(&help, "help", false, "Show blast-radius help") + fs.BoolVar(&help, "h", false, "Show blast-radius help") + fs.IntVar(&limits.MaxTotalChars, "max-total-chars", limits.MaxTotalChars, "Hard cap for markdown/text output") + fs.IntVar(&limits.MaxChangedFiles, "max-changed-files", limits.MaxChangedFiles, "Maximum changed files to include") + fs.IntVar(&limits.MaxAffected, "max-affected", limits.MaxAffected, "Maximum affected files outside diff") + fs.IntVar(&limits.MaxContext, "max-context", limits.MaxContext, "Maximum dependency context entries outside diff") + fs.IntVar(&limits.MaxSnippets, "max-snippets", limits.MaxSnippets, "Maximum impact snippets") + fs.IntVar(&limits.MaxSnippetsPerChanged, "max-snippets-per-changed", limits.MaxSnippetsPerChanged, "Maximum snippets per changed file") + fs.IntVar(&limits.SnippetRadius, "snippet-radius", limits.SnippetRadius, "Lines of context around each snippet match") + fs.IntVar(&limits.MaxSnippetChars, "max-snippet-chars", limits.MaxSnippetChars, "Maximum characters per snippet") + fs.IntVar(&limits.MaxDiffChars, "max-diff-chars", limits.MaxDiffChars, "Maximum diff section characters") + fs.IntVar(&limits.MaxDepsChars, "max-deps-chars", limits.MaxDepsChars, "Maximum dependency section characters") + fs.IntVar(&limits.MaxImportersChars, "max-importers-chars", limits.MaxImportersChars, "Maximum importer section characters") + fs.IntVar(&limits.MaxImporterFiles, "max-importer-files", limits.MaxImporterFiles, "Maximum changed files to render importer sections for") + fs.IntVar(&limits.MaxImportersPerFile, "max-importers-per-file", limits.MaxImportersPerFile, "Maximum importer/import list entries per file in JSON") + fs.Usage = func() { + printBlastRadiusUsage(fs) + } + + if err := fs.Parse(args); err != nil { + if errors.Is(err, flag.ErrHelp) { + return + } + os.Exit(2) + } + + if help { + fs.Usage() + return + } + + if fs.NArg() > 1 { + fmt.Fprintln(os.Stderr, "Usage: codemap blast-radius [--json|--markdown|--text] [--ref ] [path]") + os.Exit(2) + } + + format, err := chooseBlastRadiusFormat(jsonMode, markdownMode, textMode) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(2) + } + + root := "." + if fs.NArg() == 1 { + root = fs.Arg(0) + } + + limits = clampBlastRadiusLimits(limits) + absRoot, cleanup, err := resolveBlastRadiusRoot(root) + if err != nil { + fmt.Fprintf(os.Stderr, "Error preparing root: %v\n", err) + os.Exit(1) + } + defer cleanup() + + bundle, err := buildBlastRadiusBundle(absRoot, *ref, limits) + if err != nil { + if errors.Is(err, scanner.ErrAstGrepNotFound) { + printAstGrepInstallHint(os.Stderr, err) + } else { + fmt.Fprintf(os.Stderr, "Error building blast radius: %v\n", err) + } + os.Exit(1) + } + + switch format { + case blastRadiusFormatJSON: + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + _ = enc.Encode(bundle) + case blastRadiusFormatText: + fmt.Print(renderBlastRadiusText(bundle)) + default: + fmt.Print(renderBlastRadiusMarkdown(bundle)) + } +} + +func printBlastRadiusUsage(fs *flag.FlagSet) { + fmt.Println("codemap blast-radius - Build a compact, bounded blast-radius bundle") + fmt.Println() + fmt.Println("Usage:") + fmt.Println(" codemap blast-radius [--json|--markdown|--text] [--ref ] [path]") + fmt.Println() + fmt.Println("Examples:") + fmt.Println(" codemap blast-radius --ref main .") + fmt.Println(" codemap blast-radius --json --ref develop /path/to/repo") + fmt.Println() + fmt.Println("Flags:") + fs.PrintDefaults() + fmt.Println() + fmt.Println("Environment overrides:") + fmt.Println(" CODEMAP_BLAST_MAX_TOTAL_CHARS") + fmt.Println(" CODEMAP_BLAST_MAX_CHANGED_FILES") + fmt.Println(" CODEMAP_BLAST_MAX_AFFECTED") + fmt.Println(" CODEMAP_BLAST_MAX_CONTEXT") + fmt.Println(" CODEMAP_BLAST_MAX_SNIPPETS") + fmt.Println(" CODEMAP_BLAST_MAX_SNIPPETS_PER_CHANGED") + fmt.Println(" CODEMAP_BLAST_SNIPPET_RADIUS") + fmt.Println(" CODEMAP_BLAST_MAX_SNIPPET_CHARS") + fmt.Println(" CODEMAP_BLAST_MAX_DIFF_CHARS") + fmt.Println(" CODEMAP_BLAST_MAX_DEPS_CHARS") + fmt.Println(" CODEMAP_BLAST_MAX_IMPORTERS_CHARS") + fmt.Println(" CODEMAP_BLAST_MAX_IMPORTER_FILES") + fmt.Println(" CODEMAP_BLAST_MAX_IMPORTERS_PER_FILE") +} + +func chooseBlastRadiusFormat(jsonMode, markdownMode, textMode bool) (blastRadiusFormat, error) { + count := 0 + if jsonMode { + count++ + } + if markdownMode { + count++ + } + if textMode { + count++ + } + if count > 1 { + return "", fmt.Errorf("choose only one of --json, --markdown, or --text") + } + switch { + case jsonMode: + return blastRadiusFormatJSON, nil + case textMode: + return blastRadiusFormatText, nil + default: + return blastRadiusFormatMarkdown, nil + } +} + +func defaultBlastRadiusLimits() blastRadiusLimits { + return blastRadiusLimits{ + MaxTotalChars: envInt("CODEMAP_BLAST_MAX_TOTAL_CHARS", 24000), + MaxChangedFiles: envInt("CODEMAP_BLAST_MAX_CHANGED_FILES", 20), + MaxAffected: envInt("CODEMAP_BLAST_MAX_AFFECTED", 12), + MaxContext: envInt("CODEMAP_BLAST_MAX_CONTEXT", 8), + MaxSnippets: envInt("CODEMAP_BLAST_MAX_SNIPPETS", 8), + MaxSnippetsPerChanged: envInt("CODEMAP_BLAST_MAX_SNIPPETS_PER_CHANGED", 2), + SnippetRadius: envInt("CODEMAP_BLAST_SNIPPET_RADIUS", 2), + MaxSnippetChars: envInt("CODEMAP_BLAST_MAX_SNIPPET_CHARS", 700), + MaxDiffChars: envInt("CODEMAP_BLAST_MAX_DIFF_CHARS", 8000), + MaxDepsChars: envInt("CODEMAP_BLAST_MAX_DEPS_CHARS", 5000), + MaxImportersChars: envInt("CODEMAP_BLAST_MAX_IMPORTERS_CHARS", 6000), + MaxImporterFiles: envInt("CODEMAP_BLAST_MAX_IMPORTER_FILES", 8), + MaxImportersPerFile: envInt("CODEMAP_BLAST_MAX_IMPORTERS_PER_FILE", 12), + } +} + +func envInt(key string, fallback int) int { + raw := strings.TrimSpace(os.Getenv(key)) + if raw == "" { + return fallback + } + value, err := strconv.Atoi(raw) + if err != nil { + return fallback + } + return value +} + +func clampBlastRadiusLimits(limits blastRadiusLimits) blastRadiusLimits { + if limits.MaxTotalChars < 0 { + limits.MaxTotalChars = 0 + } + if limits.MaxChangedFiles < 0 { + limits.MaxChangedFiles = 0 + } + if limits.MaxAffected < 0 { + limits.MaxAffected = 0 + } + if limits.MaxContext < 0 { + limits.MaxContext = 0 + } + if limits.MaxSnippets < 0 { + limits.MaxSnippets = 0 + } + if limits.MaxSnippetsPerChanged < 0 { + limits.MaxSnippetsPerChanged = 0 + } + if limits.SnippetRadius < 0 { + limits.SnippetRadius = 0 + } + if limits.MaxSnippetChars < 0 { + limits.MaxSnippetChars = 0 + } + if limits.MaxDiffChars < 0 { + limits.MaxDiffChars = 0 + } + if limits.MaxDepsChars < 0 { + limits.MaxDepsChars = 0 + } + if limits.MaxImportersChars < 0 { + limits.MaxImportersChars = 0 + } + if limits.MaxImporterFiles < 0 { + limits.MaxImporterFiles = 0 + } + if limits.MaxImportersPerFile < 0 { + limits.MaxImportersPerFile = 0 + } + return limits +} + +func resolveBlastRadiusRoot(root string) (string, func(), error) { + cleanup := func() {} + _, localErr := os.Stat(root) + if isGitHubURL(root) && localErr != nil { + repoName := extractRepoName(root) + tempDir, err := cloneRepo(root, repoName) + if err != nil { + return "", cleanup, err + } + cleanup = func() { _ = os.RemoveAll(tempDir) } + root = tempDir + } + + absRoot, err := filepath.Abs(root) + if err != nil { + return "", cleanup, err + } + return absRoot, cleanup, nil +} + +func buildBlastRadiusBundle(absRoot, ref string, limits blastRadiusLimits) (blastRadiusBundle, error) { + diffProject, err := buildBlastRadiusDiffProject(absRoot, ref) + if err != nil { + return blastRadiusBundle{}, err + } + diffTotal := len(diffProject.Files) + diffCapped := capBlastRadiusProject(diffProject, limits.MaxChangedFiles) + changedSet := make(map[string]bool, len(diffProject.Files)) + for _, file := range diffProject.Files { + changedSet[filepath.ToSlash(file.Path)] = true + } + + depsProject := scanner.DepsProject{ + Root: absRoot, + Mode: "deps", + Files: nil, + ExternalDeps: map[string][]string{}, + DiffRef: ref, + } + depsCapped := depsProject + depsTotal := 0 + var fullReports []scanner.ImportersReport + var jsonReports []blastRadiusImporters + var rawImpacted []blastRadiusRelation + var impacted []blastRadiusRelation + var rawContext []blastRadiusRelation + var context []blastRadiusRelation + var snippets []blastRadiusSnippet + + if diffTotal > 0 { + depsProject, err = buildBlastRadiusDepsProject(absRoot, ref, changedSet) + if err != nil { + return blastRadiusBundle{}, err + } + depsTotal = len(depsProject.Files) + depsCapped = capBlastRadiusDepsProject(depsProject, limits.MaxChangedFiles) + + fg, err := scanner.BuildFileGraph(absRoot) + if err != nil { + return blastRadiusBundle{}, err + } + + for _, file := range diffCapped.Files { + report := buildImportersReportFromGraph(absRoot, file.Path, fg) + fullReports = append(fullReports, report) + jsonReports = append(jsonReports, capBlastRadiusImportersReport(report, limits.MaxImportersPerFile)) + } + + rawImpacted = collectBlastRadiusImpacted(fullReports, changedSet) + rawContext = collectBlastRadiusContext(fullReports, changedSet) + impacted = capBlastRadiusRelations(rawImpacted, limits.MaxAffected) + context = capBlastRadiusRelations(rawContext, limits.MaxContext) + snippets = buildBlastRadiusSnippets(absRoot, diffProject.Files, depsProject.Files, impacted, context, limits) + } + + summary := buildBlastRadiusSummary(diffCapped.Files, diffTotal, impacted, rawImpacted, context, rawContext, fullReports) + + bundle := blastRadiusBundle{ + Root: absRoot, + Ref: ref, + Summary: summary, + Diff: blastRadiusDiff{ + Root: diffCapped.Root, + Name: diffCapped.Name, + Mode: diffCapped.Mode, + Files: diffCapped.Files, + DiffRef: diffCapped.DiffRef, + Impact: diffCapped.Impact, + Depth: diffCapped.Depth, + Only: diffCapped.Only, + Exclude: diffCapped.Exclude, + ChangedFilesTotal: diffTotal, + }, + Deps: blastRadiusDeps{ + Root: depsCapped.Root, + Mode: depsCapped.Mode, + Files: depsCapped.Files, + ExternalDeps: depsCapped.ExternalDeps, + DiffRef: depsCapped.DiffRef, + ChangedFilesTotal: depsTotal, + }, + Importers: jsonReports, + Limits: limits, + ImpactedOutsideDiff: impacted, + DependencyContextOutsideDiff: context, + Snippets: snippets, + } + bundle.Rendered = buildBlastRadiusRendered(diffCapped, depsCapped, fullReports, limits) + return bundle, nil +} + +func buildBlastRadiusDiffProject(absRoot, ref string) (scanner.Project, error) { + diffInfo, err := scanner.GitDiffInfo(absRoot, ref) + if err != nil { + return scanner.Project{}, err + } + + cfg := config.Load(absRoot) + gitCache := scanner.NewGitIgnoreCache(absRoot) + files, err := scanner.ScanFiles(absRoot, gitCache, cfg.Only, cfg.Exclude) + if err != nil { + return scanner.Project{}, err + } + files = scanner.FilterToChangedWithInfo(files, diffInfo) + impact := scanner.AnalyzeImpact(absRoot, files) + + return scanner.Project{ + Root: absRoot, + Mode: "tree", + Files: files, + DiffRef: ref, + Impact: impact, + Depth: cfg.Depth, + Only: cfg.Only, + Exclude: cfg.Exclude, + }, nil +} + +func buildBlastRadiusDepsProject(absRoot, ref string, changedSet map[string]bool) (scanner.DepsProject, error) { + analyses, err := scanForDepsWithHint(absRoot) + if err != nil { + return scanner.DepsProject{}, err + } + analyses = scanner.FilterAnalysisToChanged(analyses, changedSet) + return scanner.DepsProject{ + Root: absRoot, + Mode: "deps", + Files: analyses, + ExternalDeps: scanner.ReadExternalDeps(absRoot), + DiffRef: ref, + }, nil +} + +func capBlastRadiusProject(project scanner.Project, max int) scanner.Project { + if max >= len(project.Files) { + return project + } + project.Files = append([]scanner.FileInfo(nil), project.Files[:max]...) + if len(project.Impact) > max { + project.Impact = append([]scanner.ImpactInfo(nil), project.Impact[:max]...) + } + return project +} + +func capBlastRadiusDepsProject(project scanner.DepsProject, max int) scanner.DepsProject { + if max >= len(project.Files) { + return project + } + project.Files = append([]scanner.FileAnalysis(nil), project.Files[:max]...) + return project +} + +func capBlastRadiusImportersReport(report scanner.ImportersReport, max int) blastRadiusImporters { + cappedImporters := capStringSlice(report.Importers, max) + cappedImports := capStringSlice(report.Imports, max) + cappedHubImports := capStringSlice(report.HubImports, max) + return blastRadiusImporters{ + Root: report.Root, + Mode: report.Mode, + File: report.File, + Importers: cappedImporters, + Imports: cappedImports, + HubImports: cappedHubImports, + ImporterCount: report.ImporterCount, + IsHub: report.IsHub, + ImportersTotal: len(report.Importers), + ImportsTotal: len(report.Imports), + HubImportsTotal: len(report.HubImports), + } +} + +func capBlastRadiusRelations(relations []blastRadiusRelation, max int) []blastRadiusRelation { + if max >= len(relations) { + return append([]blastRadiusRelation(nil), relations...) + } + return append([]blastRadiusRelation(nil), relations[:max]...) +} + +func capStringSlice(items []string, max int) []string { + if max >= len(items) { + return append([]string(nil), items...) + } + return append([]string(nil), items[:max]...) +} + +func collectBlastRadiusImpacted(reports []scanner.ImportersReport, changedSet map[string]bool) []blastRadiusRelation { + var impacted []blastRadiusRelation + seen := make(map[string]bool) + for _, report := range reports { + for _, importer := range report.Importers { + importer = filepath.ToSlash(importer) + if changedSet[importer] { + continue + } + key := importer + "|" + report.File + if seen[key] { + continue + } + seen[key] = true + impacted = append(impacted, blastRadiusRelation{ + Path: importer, + Via: report.File, + Relation: "imports_changed_file", + ViaIsHub: report.IsHub, + ViaImporterCount: report.ImporterCount, + }) + } + } + + sort.Slice(impacted, func(i, j int) bool { + if impacted[i].ViaImporterCount != impacted[j].ViaImporterCount { + return impacted[i].ViaImporterCount > impacted[j].ViaImporterCount + } + if impacted[i].Path != impacted[j].Path { + return impacted[i].Path < impacted[j].Path + } + return impacted[i].Via < impacted[j].Via + }) + return impacted +} + +func collectBlastRadiusContext(reports []scanner.ImportersReport, changedSet map[string]bool) []blastRadiusRelation { + var context []blastRadiusRelation + seen := make(map[string]bool) + for _, report := range reports { + hubImports := make(map[string]bool, len(report.HubImports)) + for _, hub := range report.HubImports { + hubImports[filepath.ToSlash(hub)] = true + } + for _, imp := range report.Imports { + imp = filepath.ToSlash(imp) + if changedSet[imp] { + continue + } + key := imp + "|" + report.File + if seen[key] { + continue + } + seen[key] = true + relation := "internal_dependency" + if hubImports[imp] { + relation = "shared_hub_dependency" + } + context = append(context, blastRadiusRelation{ + Path: imp, + Via: report.File, + Relation: relation, + IsHub: hubImports[imp], + }) + } + } + + sort.Slice(context, func(i, j int) bool { + leftOrder := 1 + rightOrder := 1 + if context[i].Relation == "shared_hub_dependency" { + leftOrder = 0 + } + if context[j].Relation == "shared_hub_dependency" { + rightOrder = 0 + } + if leftOrder != rightOrder { + return leftOrder < rightOrder + } + if context[i].Path != context[j].Path { + return context[i].Path < context[j].Path + } + return context[i].Via < context[j].Via + }) + return context +} + +func buildBlastRadiusSummary(diffFiles []scanner.FileInfo, diffTotal int, impacted []blastRadiusRelation, rawImpacted []blastRadiusRelation, context []blastRadiusRelation, rawContext []blastRadiusRelation, reports []scanner.ImportersReport) blastRadiusSummary { + summary := blastRadiusSummary{ + ChangedFiles: len(diffFiles), + ChangedFilesTotal: diffTotal, + FilesWithDependents: countReportsWithDependents(reports), + ImpactedOutsideDiffTotal: uniqueRelationPaths(rawImpacted), + ImpactedOutsideDiffShown: uniqueRelationPaths(impacted), + DependencyContextOutsideDiffTotal: uniqueRelationPaths(rawContext), + DependencyContextOutsideDiffShown: uniqueRelationPaths(context), + MaxDirectDependents: maxDirectDependents(reports), + } + + for _, report := range reports { + if report.ImporterCount == 0 { + continue + } + if summary.HighestBlastRadius == nil || report.ImporterCount > summary.HighestBlastRadius.ImporterCount || (report.ImporterCount == summary.HighestBlastRadius.ImporterCount && report.File < summary.HighestBlastRadius.File) { + summary.HighestBlastRadius = &blastRadiusHighest{ + File: report.File, + ImporterCount: report.ImporterCount, + } + } + } + return summary +} + +func countReportsWithDependents(reports []scanner.ImportersReport) int { + count := 0 + for _, report := range reports { + if report.ImporterCount > 0 { + count++ + } + } + return count +} + +func uniqueRelationPaths(items []blastRadiusRelation) int { + seen := make(map[string]bool, len(items)) + for _, item := range items { + seen[item.Path] = true + } + return len(seen) +} + +func maxDirectDependents(reports []scanner.ImportersReport) int { + max := 0 + for _, report := range reports { + if report.ImporterCount > max { + max = report.ImporterCount + } + } + return max +} + +func buildBlastRadiusSnippets(root string, diffFiles []scanner.FileInfo, depsFiles []scanner.FileAnalysis, impacted []blastRadiusRelation, context []blastRadiusRelation, limits blastRadiusLimits) []blastRadiusSnippet { + if limits.MaxSnippets == 0 || limits.MaxSnippetsPerChanged == 0 { + return nil + } + + changedMeta := make(map[string]blastChangedMeta, len(diffFiles)) + for _, file := range diffFiles { + meta := newBlastChangedMeta(file.Path) + changedMeta[filepath.ToSlash(file.Path)] = meta + } + for _, file := range depsFiles { + meta := newBlastChangedMeta(file.Path) + meta.Functions = append([]string(nil), file.Functions...) + changedMeta[filepath.ToSlash(file.Path)] = meta + } + + var snippets []blastRadiusSnippet + perChanged := make(map[string]int) + + for _, item := range impacted { + if len(snippets) >= limits.MaxSnippets { + break + } + if perChanged[item.Via] >= limits.MaxSnippetsPerChanged { + continue + } + snippet := findBlastRadiusSnippet(root, item.Path, item.Via, "impacted_outside_diff", fmt.Sprintf("depends on changed file %s", item.Via), changedMeta, limits) + if snippet == nil { + continue + } + snippets = append(snippets, *snippet) + perChanged[item.Via]++ + } + + for _, item := range context { + if len(snippets) >= limits.MaxSnippets { + break + } + if perChanged[item.Via] >= limits.MaxSnippetsPerChanged { + continue + } + snippet := findBlastRadiusSnippet(root, item.Path, item.Via, "dependency_context_outside_diff", fmt.Sprintf("reachable from changed file %s", item.Via), changedMeta, limits) + if snippet == nil { + continue + } + snippets = append(snippets, *snippet) + perChanged[item.Via]++ + } + + return snippets +} + +func newBlastChangedMeta(file string) blastChangedMeta { + file = filepath.ToSlash(file) + stem := strings.TrimSuffix(path.Base(file), path.Ext(file)) + dir := path.Dir(file) + if dir == "." { + dir = "" + } + dirBase := "" + if dir != "" { + dirBase = path.Base(dir) + } + return blastChangedMeta{ + Stem: stem, + Dir: dir, + DirBase: dirBase, + PathNoExt: strings.TrimSuffix(file, path.Ext(file)), + } +} + +func findBlastRadiusSnippet(root, targetPath, via, category, reason string, changedMeta map[string]blastChangedMeta, limits blastRadiusLimits) *blastRadiusSnippet { + absPath := filepath.Join(root, filepath.FromSlash(targetPath)) + data, err := os.ReadFile(absPath) + if err != nil { + return nil + } + content := string(data) + if !utf8.ValidString(content) { + content = strings.ToValidUTF8(content, "\uFFFD") + } + lines := strings.Split(strings.ReplaceAll(content, "\r\n", "\n"), "\n") + if len(lines) == 0 { + return nil + } + + for _, term := range blastSnippetTerms(changedMeta[via]) { + for idx, line := range lines { + if !blastLineMatchesTerm(line, term) { + continue + } + language := scanner.DetectLanguage(targetPath) + if language == "" { + language = "text" + } + return &blastRadiusSnippet{ + Category: category, + Path: targetPath, + Via: via, + Reason: reason, + MatchedTerm: term.Value, + MatchKind: term.Kind, + Language: language, + Excerpt: buildBlastSnippetExcerpt(lines, idx, limits.SnippetRadius, limits.MaxSnippetChars), + } + } + } + + return nil +} + +type blastSnippetTerm struct { + Value string + Kind string +} + +func blastSnippetTerms(meta blastChangedMeta) []blastSnippetTerm { + var functions []string + functions = append(functions, meta.Functions...) + sort.Slice(functions, func(i, j int) bool { + if utf8.RuneCountInString(functions[i]) != utf8.RuneCountInString(functions[j]) { + return utf8.RuneCountInString(functions[i]) > utf8.RuneCountInString(functions[j]) + } + return functions[i] < functions[j] + }) + + var terms []blastSnippetTerm + seen := make(map[string]bool) + for _, fn := range functions { + fn = strings.TrimSpace(fn) + if fn == "" || seen[fn] { + continue + } + seen[fn] = true + terms = append(terms, blastSnippetTerm{Value: fn, Kind: "symbol"}) + } + + for _, candidate := range []blastSnippetTerm{ + {Value: meta.PathNoExt, Kind: "path"}, + {Value: meta.Dir, Kind: "path"}, + {Value: meta.DirBase, Kind: "identifier"}, + {Value: meta.Stem, Kind: "identifier"}, + } { + value := strings.TrimSpace(candidate.Value) + if value == "" || seen[value] { + continue + } + seen[value] = true + terms = append(terms, blastSnippetTerm{Value: value, Kind: candidate.Kind}) + } + + return terms +} + +func blastLineMatchesTerm(line string, term blastSnippetTerm) bool { + switch term.Kind { + case "symbol": + pattern := regexp.MustCompile(`\b` + regexp.QuoteMeta(term.Value) + `\b`) + return pattern.MatchString(line) + default: + return strings.Contains(line, term.Value) + } +} + +func buildBlastSnippetExcerpt(lines []string, index, radius, maxChars int) string { + start := index - radius + if start < 0 { + start = 0 + } + end := index + radius + 1 + if end > len(lines) { + end = len(lines) + } + + var excerpt []string + for i := start; i < end; i++ { + excerpt = append(excerpt, fmt.Sprintf("%4d | %s", i+1, lines[i])) + } + text := strings.Join(excerpt, "\n") + if runeLen(text) <= maxChars { + return text + } + return truncateRunes(text, maxChars, "\n... [truncated]") +} + +func buildBlastRadiusRendered(diffProject scanner.Project, depsProject scanner.DepsProject, reports []scanner.ImportersReport, limits blastRadiusLimits) blastRadiusRendered { + rendered := blastRadiusRendered{ + Diff: truncateBlastRadiusText(renderDiffProject(diffProject), limits.MaxDiffChars, "diff"), + Deps: truncateBlastRadiusText(renderDepsProject(depsProject), limits.MaxDepsChars, "deps"), + } + + importerBudget := limits.MaxImportersChars + for idx, report := range reports { + if idx >= limits.MaxImporterFiles || importerBudget <= 0 { + break + } + perFileBudget := minInt(importerBudget, 1200) + text := truncateBlastRadiusText(renderImportersReportString(report), perFileBudget, "importers:"+report.File) + rendered.Importers = append(rendered.Importers, blastRadiusRenderedImporter{ + File: report.File, + Text: text, + }) + importerBudget -= runeLen(text) + } + + return rendered +} + +func renderBlastRadiusMarkdown(bundle blastRadiusBundle) string { + builder := newBlastOutputBuilder(bundle.Limits.MaxTotalChars) + + 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)) + 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)) + 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)) + } + 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)) + if !builder.Append(summary.String(), "summary") { + return builder.String() + } + + if len(bundle.ImpactedOutsideDiff) > 0 { + var section strings.Builder + section.WriteString("## Affected Outside Diff\n\n") + for _, item := range bundle.ImpactedOutsideDiff { + section.WriteString(fmt.Sprintf("- `%s` depends on changed file `%s`", item.Path, item.Via)) + if item.ViaIsHub { + section.WriteString(fmt.Sprintf(" [hub, %d dependents]", item.ViaImporterCount)) + } + section.WriteString("\n") + } + section.WriteString("\n") + if !builder.Append(section.String(), "affected outside diff") { + return builder.String() + } + } + + if len(bundle.DependencyContextOutsideDiff) > 0 { + var section strings.Builder + section.WriteString("## Dependency Context Outside Diff\n\n") + for _, item := range bundle.DependencyContextOutsideDiff { + section.WriteString(fmt.Sprintf("- changed file `%s` reaches `%s`", item.Via, item.Path)) + if item.Relation == "shared_hub_dependency" { + section.WriteString(" [shared hub]") + } + section.WriteString("\n") + } + section.WriteString("\n") + if !builder.Append(section.String(), "dependency context") { + return builder.String() + } + } + + if len(bundle.Snippets) > 0 { + var section strings.Builder + section.WriteString("## Impact Snippets\n") + for _, snippet := range bundle.Snippets { + section.WriteString("\n") + section.WriteString(fmt.Sprintf("### `%s` via `%s`\n\n", snippet.Path, snippet.Via)) + section.WriteString(fmt.Sprintf("- Reason: %s\n", snippet.Reason)) + section.WriteString(fmt.Sprintf("- Match: `%s` (%s)\n\n", snippet.MatchedTerm, snippet.MatchKind)) + section.WriteString("```" + snippet.Language + "\n") + section.WriteString(snippet.Excerpt) + section.WriteString("\n```\n") + } + section.WriteString("\n") + if !builder.Append(section.String(), "impact snippets") { + return builder.String() + } + } + + diffSection := "## Diff\n\n```text\n" + bundle.Rendered.Diff + "\n```\n\n" + if !builder.Append(diffSection, "diff section") { + return builder.String() + } + + depsSection := "## Dependency Flow (Changed Files)\n\n```text\n" + bundle.Rendered.Deps + "\n```\n" + if !builder.Append(depsSection, "deps section") { + return builder.String() + } + + if len(bundle.Rendered.Importers) > 0 { + var section strings.Builder + section.WriteString("\n## Importers\n") + for _, importer := range bundle.Rendered.Importers { + section.WriteString(fmt.Sprintf("\n### `%s`\n\n```text\n%s\n```\n", importer.File, importer.Text)) + } + if len(bundle.Importers) > len(bundle.Rendered.Importers) { + section.WriteString("\n... [additional importer sections omitted]\n") + } + builder.Append(section.String(), "importers section") + } + + return builder.String() +} + +func renderBlastRadiusText(bundle blastRadiusBundle) string { + builder := newBlastOutputBuilder(bundle.Limits.MaxTotalChars) + + var summary strings.Builder + summary.WriteString("CODEMAP BLAST RADIUS\n") + summary.WriteString(fmt.Sprintf("root=%s\n", bundle.Root)) + summary.WriteString(fmt.Sprintf("ref=%s\n\n", bundle.Ref)) + summary.WriteString("[summary]\n") + summary.WriteString(fmt.Sprintf("changed_files=%d/%d\n", bundle.Summary.ChangedFiles, bundle.Summary.ChangedFilesTotal)) + summary.WriteString(fmt.Sprintf("files_with_dependents=%d\n", bundle.Summary.FilesWithDependents)) + summary.WriteString(fmt.Sprintf("impacted_outside_diff=%d/%d\n", bundle.Summary.ImpactedOutsideDiffShown, bundle.Summary.ImpactedOutsideDiffTotal)) + summary.WriteString(fmt.Sprintf("dependency_context_outside_diff=%d/%d\n", bundle.Summary.DependencyContextOutsideDiffShown, bundle.Summary.DependencyContextOutsideDiffTotal)) + summary.WriteString(fmt.Sprintf("output_budgets=%d_total,%d_diff,%d_deps,%d_importers\n", bundle.Limits.MaxTotalChars, bundle.Limits.MaxDiffChars, bundle.Limits.MaxDepsChars, bundle.Limits.MaxImportersChars)) + summary.WriteString(fmt.Sprintf("snippet_limits=%d_total,%d_per_changed,%d_chars\n\n", bundle.Limits.MaxSnippets, bundle.Limits.MaxSnippetsPerChanged, bundle.Limits.MaxSnippetChars)) + if !builder.Append(summary.String(), "summary") { + return builder.String() + } + + if len(bundle.ImpactedOutsideDiff) > 0 { + var section strings.Builder + section.WriteString("[affected_outside_diff]\n") + for _, item := range bundle.ImpactedOutsideDiff { + section.WriteString(fmt.Sprintf("%s <= %s\n", item.Path, item.Via)) + } + section.WriteString("\n") + if !builder.Append(section.String(), "affected outside diff") { + return builder.String() + } + } + + if len(bundle.DependencyContextOutsideDiff) > 0 { + var section strings.Builder + section.WriteString("[dependency_context_outside_diff]\n") + for _, item := range bundle.DependencyContextOutsideDiff { + section.WriteString(fmt.Sprintf("%s => %s", item.Via, item.Path)) + if item.Relation == "shared_hub_dependency" { + section.WriteString(" [shared hub]") + } + section.WriteString("\n") + } + section.WriteString("\n") + if !builder.Append(section.String(), "dependency context") { + return builder.String() + } + } + + if len(bundle.Snippets) > 0 { + var section strings.Builder + section.WriteString("[impact_snippets]\n") + for _, snippet := range bundle.Snippets { + section.WriteString(fmt.Sprintf("\n%s <= %s [%s]\n", snippet.Path, snippet.Via, snippet.MatchedTerm)) + section.WriteString(snippet.Excerpt) + section.WriteString("\n") + } + section.WriteString("\n") + if !builder.Append(section.String(), "impact snippets") { + return builder.String() + } + } + + if !builder.Append("[diff]\n"+bundle.Rendered.Diff+"\n", "diff section") { + return builder.String() + } + if !builder.Append("[deps]\n"+bundle.Rendered.Deps+"\n", "deps section") { + return builder.String() + } + + if len(bundle.Rendered.Importers) > 0 { + var section strings.Builder + for _, importer := range bundle.Rendered.Importers { + section.WriteString(fmt.Sprintf("\n[importers] %s\n%s\n", importer.File, importer.Text)) + } + if len(bundle.Importers) > len(bundle.Rendered.Importers) { + section.WriteString("\n... [additional importer sections omitted]\n") + } + builder.Append(section.String(), "importers section") + } + + return builder.String() +} + +func newBlastOutputBuilder(total int) *blastOutputBuilder { + return &blastOutputBuilder{total: total, remaining: total} +} + +func (b *blastOutputBuilder) Append(text, label string) bool { + if b.remaining <= 0 { + return false + } + if runeLen(text) <= b.remaining { + b.builder.WriteString(text) + b.remaining -= runeLen(text) + return true + } + marker := fmt.Sprintf("\n... [%s omitted after total budget %d chars]\n", label, b.total) + keep := b.remaining - runeLen(marker) + if keep < 0 { + keep = 0 + } + b.builder.WriteString(firstRunes(text, keep)) + b.builder.WriteString(marker) + b.remaining = 0 + return false +} + +func (b *blastOutputBuilder) String() string { + return b.builder.String() +} + +func truncateBlastRadiusText(text string, maxChars int, label string) string { + if maxChars <= 0 { + return fmt.Sprintf("... [%s omitted]\n", label) + } + if runeLen(text) <= maxChars { + return text + } + marker := fmt.Sprintf("\n... [%s truncated to %d chars]\n", label, maxChars) + keep := maxChars - runeLen(marker) + if keep < 0 { + keep = 0 + } + return firstRunes(text, keep) + marker +} + +func renderDiffProject(project scanner.Project) string { + var buf bytes.Buffer + render.Tree(&buf, project) + return stripANSI(buf.String()) +} + +func renderDepsProject(project scanner.DepsProject) string { + var buf bytes.Buffer + render.Depgraph(&buf, project) + return stripANSI(buf.String()) +} + +func renderImportersReportString(report scanner.ImportersReport) string { + var buf bytes.Buffer + renderImportersReport(&buf, report) + return buf.String() +} + +func renderImportersReport(w io.Writer, report scanner.ImportersReport) { + if len(report.Importers) >= 3 { + fmt.Fprintf(w, "⚠️ HUB FILE: %s\n", report.File) + fmt.Fprintf(w, " Imported by %d files - changes have wide impact!\n", len(report.Importers)) + fmt.Fprintln(w) + fmt.Fprintln(w, " Dependents:") + for i, imp := range report.Importers { + if i >= 5 { + fmt.Fprintf(w, " ... and %d more\n", len(report.Importers)-5) + break + } + fmt.Fprintf(w, " • %s\n", imp) + } + } else if len(report.Importers) > 0 { + fmt.Fprintf(w, "📍 File: %s\n", report.File) + fmt.Fprintf(w, " Imported by %d file(s)\n", len(report.Importers)) + for _, imp := range report.Importers { + fmt.Fprintf(w, " • %s\n", imp) + } + } + + if len(report.HubImports) > 0 { + if len(report.Importers) == 0 { + fmt.Fprintf(w, "📍 File: %s\n", report.File) + } + fmt.Fprintf(w, " Imports %d hub(s): %s\n", len(report.HubImports), strings.Join(report.HubImports, ", ")) + } +} + +func buildImportersReportFromGraph(root, file string, fg *scanner.FileGraph) scanner.ImportersReport { + if filepath.IsAbs(file) { + if rel, err := filepath.Rel(root, file); err == nil { + file = rel + } + } + file = filepath.ToSlash(file) + + importers := append([]string(nil), fg.Importers[file]...) + imports := append([]string(nil), fg.Imports[file]...) + sort.Strings(importers) + sort.Strings(imports) + + report := scanner.ImportersReport{ + Root: root, + Mode: "importers", + File: file, + Importers: importers, + Imports: imports, + ImporterCount: len(importers), + IsHub: len(importers) >= 3, + } + + for _, imp := range imports { + if fg.IsHub(imp) { + report.HubImports = append(report.HubImports, imp) + } + } + sort.Strings(report.HubImports) + return report +} + +func printAstGrepInstallHint(w io.Writer, err error) { + fmt.Fprintf(w, "Error: %v\n", err) + fmt.Fprintln(w) + fmt.Fprintln(w, "The --deps feature requires ast-grep. Install it with:") + fmt.Fprintln(w, " brew install ast-grep # macOS/Linux (installs as 'sg')") + fmt.Fprintln(w, " cargo install ast-grep # installs as 'ast-grep'") + fmt.Fprintln(w, " pipx install ast-grep # installs as 'ast-grep'") + fmt.Fprintln(w, " python3 -m pip install ast-grep-cli") + fmt.Fprintln(w) + fmt.Fprintln(w, "Standard release tarballs ship codemap without the ast-grep binary.") + fmt.Fprintln(w, "Use a codemap-full archive for self-contained CI installs, or install ast-grep separately.") +} + +func stripANSI(text string) string { + return ansiSequencePattern.ReplaceAllString(text, "") +} + +func runeLen(text string) int { + return utf8.RuneCountInString(text) +} + +func firstRunes(text string, max int) string { + if max <= 0 { + return "" + } + if runeLen(text) <= max { + return text + } + var builder strings.Builder + count := 0 + for _, r := range text { + if count >= max { + break + } + builder.WriteRune(r) + count++ + } + return builder.String() +} + +func truncateRunes(text string, max int, suffix string) string { + if runeLen(text) <= max { + return text + } + keep := max - runeLen(suffix) + if keep < 0 { + keep = 0 + } + return firstRunes(text, keep) + suffix +} + +func minInt(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/blast_radius_test.go b/blast_radius_test.go new file mode 100644 index 0000000..3f28119 --- /dev/null +++ b/blast_radius_test.go @@ -0,0 +1,129 @@ +package main + +import ( + "encoding/json" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "codemap/scanner" +) + +func makeBlastRadiusGitRepo(t *testing.T) string { + t.Helper() + + root := t.TempDir() + files := map[string]string{ + "go.mod": "module example.com/demo\n\ngo 1.22\n", + "pkg/math/math.go": `package math + +func ComputeTotal(a, b int) int { + return a + b +} +`, + "internal/service/service.go": `package service + +import "example.com/demo/pkg/math" + +func Run() int { + return math.ComputeTotal(2, 3) +} +`, + "internal/worker/worker.go": `package worker + +import "example.com/demo/pkg/math" + +func Work() int { + return math.ComputeTotal(4, 5) +} +`, + "main.go": `package main + +import "example.com/demo/internal/service" + +func main() { + _ = service.Run() +} +`, + } + + for relPath, content := range files { + fullPath := filepath.Join(root, relPath) + if err := os.MkdirAll(filepath.Dir(fullPath), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(fullPath, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + } + + runGitMainTestCmd(t, root, "init") + runGitMainTestCmd(t, root, "add", ".") + runGitMainTestCmd(t, root, "-c", "user.name=Test", "-c", "user.email=test@example.com", "commit", "-m", "init") + runGitMainTestCmd(t, root, "branch", "-M", "main") + + updated := `package math + +func ComputeTotal(a, b int) int { + return a + b + 1 +} +` + if err := os.WriteFile(filepath.Join(root, "pkg", "math", "math.go"), []byte(updated), 0o644); err != nil { + t.Fatal(err) + } + + return root +} + +func TestBlastRadiusSubcommandMarkdownAndJSON(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not available") + } + if !scanner.NewAstGrepAnalyzer().Available() { + t.Skip("ast-grep not available") + } + + root := makeBlastRadiusGitRepo(t) + + markdown, stderr, err := runCodemapWithInput("", "blast-radius", "--ref", "HEAD", root) + if err != nil { + t.Fatalf("blast-radius markdown failed: %v\nstderr=%s", err, stderr) + } + for _, check := range []string{ + "# Codemap Blast Radius", + "## Affected Outside Diff", + "## Impact Snippets", + "`internal/service/service.go` via `pkg/math/math.go`", + "ComputeTotal", + } { + if !strings.Contains(markdown, check) { + t.Fatalf("expected %q in markdown output, got:\n%s", check, markdown) + } + } + + jsonOut, stderr, err := runCodemapWithInput("", "blast-radius", "--json", "--ref", "HEAD", root) + if err != nil { + t.Fatalf("blast-radius json failed: %v\nstderr=%s", err, stderr) + } + var bundle blastRadiusBundle + if err := json.Unmarshal([]byte(jsonOut), &bundle); err != nil { + t.Fatalf("expected blast-radius JSON output, got error %v with body:\n%s", err, jsonOut) + } + if bundle.Ref != "HEAD" { + t.Fatalf("bundle ref = %q, want HEAD", bundle.Ref) + } + if bundle.Summary.ChangedFiles != 1 || bundle.Summary.ChangedFilesTotal != 1 { + t.Fatalf("unexpected changed-file summary: %+v", bundle.Summary) + } + if bundle.Summary.ImpactedOutsideDiffShown == 0 { + t.Fatalf("expected impacted files outside diff, got %+v", bundle.Summary) + } + if len(bundle.Snippets) == 0 { + t.Fatalf("expected at least one impact snippet, got %+v", bundle) + } + if bundle.Snippets[0].MatchedTerm != "ComputeTotal" { + t.Fatalf("expected snippet match to target ComputeTotal, got %+v", bundle.Snippets[0]) + } +} diff --git a/main.go b/main.go index 9e4e20a..65844ed 100644 --- a/main.go +++ b/main.go @@ -148,6 +148,12 @@ func main() { return } + // Handle "blast-radius" subcommand before global flag parsing + if len(os.Args) >= 2 && os.Args[1] == "blast-radius" { + runBlastRadiusSubcommand(os.Args[2:]) + return + } + skylineMode := flag.Bool("skyline", false, "Enable skyline visualization mode") animateMode := flag.Bool("animate", false, "Enable animation (use with --skyline)") depsMode := flag.Bool("deps", false, "Enable dependency graph mode (function/import analysis)") @@ -212,6 +218,7 @@ func main() { fmt.Println(" codemap hook pre-compact # Save state before compact") fmt.Println(" codemap hook session-stop # Session summary") fmt.Println(" codemap handoff [path] # Build handoff artifact for agent switching") + fmt.Println(" codemap blast-radius [path] # Compact bounded blast-radius bundle") fmt.Println() fmt.Println("Project config:") fmt.Println(" codemap config init # Create .codemap/config.json (auto-detects extensions)") @@ -408,16 +415,7 @@ func runDepsMode(absRoot, root string, jsonMode bool, diffRef string, changedFil analyses, err = scanForDepsWithHint(root) if err != nil { if errors.Is(err, scanner.ErrAstGrepNotFound) { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - fmt.Fprintln(os.Stderr, "") - fmt.Fprintln(os.Stderr, "The --deps feature requires ast-grep. Install it with:") - fmt.Fprintln(os.Stderr, " brew install ast-grep # macOS/Linux (installs as 'sg')") - fmt.Fprintln(os.Stderr, " cargo install ast-grep # installs as 'ast-grep'") - fmt.Fprintln(os.Stderr, " pipx install ast-grep # installs as 'ast-grep'") - fmt.Fprintln(os.Stderr, " python3 -m pip install ast-grep-cli") - fmt.Fprintln(os.Stderr, "") - fmt.Fprintln(os.Stderr, "Standard release tarballs ship codemap without the ast-grep binary.") - fmt.Fprintln(os.Stderr, "Use a codemap-full archive for self-contained CI installs, or install ast-grep separately.") + printAstGrepInstallHint(os.Stderr, err) } else { fmt.Fprintf(os.Stderr, "Error scanning dependencies: %v\n", err) } @@ -541,33 +539,7 @@ func buildImportersReport(root, file string) (scanner.ImportersReport, error) { if err != nil { return scanner.ImportersReport{}, err } - - // Handle absolute paths - convert to relative - if filepath.IsAbs(file) { - if rel, err := filepath.Rel(root, file); err == nil { - file = rel - } - } - - importers := fg.Importers[file] - imports := fg.Imports[file] - report := scanner.ImportersReport{ - Root: root, - Mode: "importers", - File: file, - Importers: append([]string(nil), importers...), - Imports: append([]string(nil), imports...), - ImporterCount: len(importers), - IsHub: len(importers) >= 3, - } - - for _, imp := range imports { - if fg.IsHub(imp) { - report.HubImports = append(report.HubImports, imp) - } - } - - return report, nil + return buildImportersReportFromGraph(root, file, fg), nil } func runImportersMode(root, file string, jsonMode bool) { @@ -581,34 +553,7 @@ func runImportersMode(root, file string, jsonMode bool) { _ = json.NewEncoder(os.Stdout).Encode(report) return } - - importers := report.Importers - if len(importers) >= 3 { - fmt.Printf("⚠️ HUB FILE: %s\n", report.File) - fmt.Printf(" Imported by %d files - changes have wide impact!\n", len(importers)) - fmt.Println() - fmt.Println(" Dependents:") - for i, imp := range importers { - if i >= 5 { - fmt.Printf(" ... and %d more\n", len(importers)-5) - break - } - fmt.Printf(" • %s\n", imp) - } - } else if len(importers) > 0 { - fmt.Printf("📍 File: %s\n", report.File) - fmt.Printf(" Imported by %d file(s)\n", len(importers)) - for _, imp := range importers { - fmt.Printf(" • %s\n", imp) - } - } - - if len(report.HubImports) > 0 { - if len(importers) == 0 { - fmt.Printf("📍 File: %s\n", report.File) - } - fmt.Printf(" Imports %d hub(s): %s\n", len(report.HubImports), strings.Join(report.HubImports, ", ")) - } + renderImportersReport(os.Stdout, report) } func runWatchSubcommand(subCmd, root string) { diff --git a/main_test.go b/main_test.go index 144fac2..299d2ff 100644 --- a/main_test.go +++ b/main_test.go @@ -59,6 +59,7 @@ func TestHelpFlag(t *testing.T) { "--deps", "--diff", "--ref", + "blast-radius", } for _, expected := range expectedStrings { diff --git a/scripts/codemap-blast-radius.sh b/scripts/codemap-blast-radius.sh deleted file mode 100755 index dada9c2..0000000 --- a/scripts/codemap-blast-radius.sh +++ /dev/null @@ -1,700 +0,0 @@ -#!/usr/bin/env bash - -set -euo pipefail - -usage() { - cat <<'EOF' -Usage: codemap-blast-radius.sh [--json|--markdown|--text] [--ref ] [root] - -Build a compact codemap review bundle from: - 1. codemap --diff - 2. codemap --deps --diff - 3. codemap --importers for each changed file - -Examples: - bash scripts/codemap-blast-radius.sh --markdown --ref main . - bash scripts/codemap-blast-radius.sh --json --ref develop /path/to/repo -EOF -} - -format="markdown" -ref="main" -root="." -max_total_chars="${CODEMAP_BLAST_MAX_TOTAL_CHARS:-24000}" -max_changed_files="${CODEMAP_BLAST_MAX_CHANGED_FILES:-20}" -max_affected="${CODEMAP_BLAST_MAX_AFFECTED:-12}" -max_context="${CODEMAP_BLAST_MAX_CONTEXT:-8}" -max_snippets="${CODEMAP_BLAST_MAX_SNIPPETS:-8}" -max_snippets_per_changed="${CODEMAP_BLAST_MAX_SNIPPETS_PER_CHANGED:-2}" -snippet_radius="${CODEMAP_BLAST_SNIPPET_RADIUS:-2}" -max_snippet_chars="${CODEMAP_BLAST_MAX_SNIPPET_CHARS:-700}" -max_diff_chars="${CODEMAP_BLAST_MAX_DIFF_CHARS:-8000}" -max_deps_chars="${CODEMAP_BLAST_MAX_DEPS_CHARS:-5000}" -max_importers_chars="${CODEMAP_BLAST_MAX_IMPORTERS_CHARS:-6000}" -max_importer_files="${CODEMAP_BLAST_MAX_IMPORTER_FILES:-8}" -max_importers_per_file="${CODEMAP_BLAST_MAX_IMPORTERS_PER_FILE:-12}" - -while [[ $# -gt 0 ]]; do - case "$1" in - --json) - format="json" - shift - ;; - --markdown|--md) - format="markdown" - shift - ;; - --text) - format="text" - shift - ;; - --ref) - ref="${2:-}" - shift 2 - ;; - --help|-h) - usage - exit 0 - ;; - *) - root="$1" - shift - ;; - esac -done - -if ! command -v codemap >/dev/null 2>&1; then - echo "codemap is required on PATH" >&2 - exit 1 -fi - -if ! command -v jq >/dev/null 2>&1; then - echo "jq is required on PATH" >&2 - exit 1 -fi - -strip_ansi() { - if command -v python3 >/dev/null 2>&1; then - python3 -c 'import re, sys; sys.stdout.write(re.sub(r"\x1b\[[0-9;]*[A-Za-z]", "", sys.stdin.read()))' - else - cat - fi -} - -render_codemap() { - codemap "$@" | strip_ansi -} - -truncate_chars() { - local text="$1" - local max_chars="$2" - local label="$3" - - if (( max_chars <= 0 )); then - printf '... [%s omitted]\n' "$label" - return - fi - - if ((${#text} <= max_chars)); then - printf '%s' "$text" - return - fi - - local marker - marker=$'\n... ['"$label"' truncated to '"$max_chars"$' chars]\n' - local keep_chars=$((max_chars - ${#marker})) - if (( keep_chars < 0 )); then - keep_chars=0 - fi - printf '%s%s' "${text:0:keep_chars}" "$marker" -} - -capture_codemap_block() { - local max_chars="$1" - local label="$2" - shift 2 - local text - text="$(render_codemap "$@" || true)" - truncate_chars "$text" "$max_chars" "$label" -} - -min_int() { - if (( $1 < $2 )); then - printf '%s' "$1" - else - printf '%s' "$2" - fi -} - -abs_root="$(cd "$root" && pwd)" -diff_json="$(codemap --json --diff --ref "$ref" "$abs_root")" -deps_json="$(codemap --json --deps --diff --ref "$ref" "$abs_root")" - -all_changed_files=() -while IFS= read -r file; do - all_changed_files+=("$file") -done < <(printf '%s' "$diff_json" | jq -r '.files[].path') - -changed_files=("${all_changed_files[@]:0:max_changed_files}") - -importers_json='[]' -for file in "${changed_files[@]}"; do - [[ -n "$file" ]] || continue - report="$(codemap --json --importers "$file" "$abs_root")" - report="$(jq -c \ - --argjson max "$max_importers_per_file" ' - .importers_total = .importer_count - | .imports_total = ((.imports // []) | length) - | .hub_imports_total = ((.hub_imports // []) | length) - | .importers = ((.importers // [])[:$max]) - | .imports = ((.imports // [])[:$max]) - | .hub_imports = ((.hub_imports // [])[:$max]) - ' <<<"$report")" - importers_json="$(jq -c --argjson report "$report" '. + [$report]' <<<"$importers_json")" -done - -diff_json_capped="$(jq -c \ - --argjson max "$max_changed_files" ' - .changed_files_total = (.files | length) - | .files = (.files[:$max]) - | .impact = ((.impact // [])[:$max]) -' <<<"$diff_json")" - -deps_json_capped="$(jq -c \ - --argjson max "$max_changed_files" ' - .changed_files_total = (.files | length) - | .files = (.files[:$max]) -' <<<"$deps_json")" - -raw_impacted_json="$(jq -cn \ - --argjson diff "$diff_json" \ - --argjson importers "$importers_json" ' - def changed_paths: ($diff.files | map(.path)); - [ - $importers[] as $report - | $report.importers[]? - | . as $path - | select((changed_paths | index($path)) | not) - | { - path: $path, - via: $report.file, - relation: "imports_changed_file", - via_is_hub: $report.is_hub, - via_importer_count: $report.importer_count - } - ] - | unique_by(.path + "|" + .via) -')" - -impacted_json="$(jq -c \ - --argjson max "$max_affected" ' - sort_by(-(.via_importer_count // 0), .path, .via) - | .[:$max] -' <<<"$raw_impacted_json")" - -raw_context_json="$(jq -cn \ - --argjson diff "$diff_json" \ - --argjson importers "$importers_json" ' - def changed_paths: ($diff.files | map(.path)); - [ - $importers[] as $report - | $report.imports[]? - | . as $path - | select((changed_paths | index($path)) | not) - | { - path: $path, - via: $report.file, - relation: (if (($report.hub_imports // []) | index($path)) != null then "shared_hub_dependency" else "internal_dependency" end), - is_hub: ((($report.hub_imports // []) | index($path)) != null) - } - ] - | unique_by(.path + "|" + .via) -')" - -context_json="$(jq -c \ - --argjson max "$max_context" ' - sort_by((if .relation == "shared_hub_dependency" then 0 else 1 end), .path, .via) - | .[:$max] -' <<<"$raw_context_json")" - -summary_json="$(jq -cn \ - --argjson diff "$diff_json" \ - --argjson importers "$importers_json" \ - --argjson raw_impacted "$raw_impacted_json" \ - --argjson impacted "$impacted_json" \ - --argjson raw_context "$raw_context_json" \ - --argjson context "$context_json" ' - { - changed_files: ($diff.files | length), - changed_files_total: ($diff.changed_files_total // ($diff.files | length)), - files_with_dependents: ([ $importers[] | select(.importer_count > 0) ] | length), - impacted_outside_diff_total: ($raw_impacted | map(.path) | unique | length), - impacted_outside_diff_shown: ($impacted | map(.path) | unique | length), - dependency_context_outside_diff_total: ($raw_context | map(.path) | unique | length), - dependency_context_outside_diff_shown: ($context | map(.path) | unique | length), - max_direct_dependents: (([$importers[] | .importer_count] | max) // 0), - highest_blast_radius: ( - [ $importers[] | select(.importer_count > 0) ] - | sort_by(-.importer_count, .file) - | .[0] // null - ) - } -')" - -snippets_json='[]' -if command -v python3 >/dev/null 2>&1; then - snippets_json="$( - jq -n \ - --arg root "$abs_root" \ - --argjson diff "$diff_json" \ - --argjson deps "$deps_json" \ - --argjson impacted "$impacted_json" \ - --argjson context "$context_json" \ - --argjson max_snippets "$max_snippets" \ - --argjson max_snippets_per_changed "$max_snippets_per_changed" \ - --argjson snippet_radius "$snippet_radius" \ - --argjson max_snippet_chars "$max_snippet_chars" \ - '{ - root: $root, - diff: $diff, - deps: $deps, - impacted: $impacted, - context: $context, - max_snippets: $max_snippets, - max_snippets_per_changed: $max_snippets_per_changed, - snippet_radius: $snippet_radius, - max_snippet_chars: $max_snippet_chars, - max_changed_files: '"$max_changed_files"', - max_importers_per_file: '"$max_importers_per_file"' - }' \ - | python3 -c ' -import json -import pathlib -import re -import sys - -payload = json.load(sys.stdin) -root = pathlib.Path(payload["root"]) -diff_files = payload["diff"].get("files", []) -deps_files = payload["deps"].get("files", []) -impacted = payload.get("impacted", []) -context = payload.get("context", []) -max_snippets = int(payload.get("max_snippets", 8)) -max_snippets_per_changed = int(payload.get("max_snippets_per_changed", 2)) -snippet_radius = int(payload.get("snippet_radius", 2)) -max_snippet_chars = int(payload.get("max_snippet_chars", 700)) - -lang_map = { - ".go": "go", - ".py": "python", - ".js": "javascript", - ".jsx": "javascript", - ".ts": "typescript", - ".tsx": "typescript", - ".swift": "swift", - ".kt": "kotlin", - ".kts": "kotlin", - ".java": "java", - ".rb": "ruby", - ".rs": "rust", - ".sh": "bash", -} - -changed_meta = {} -for item in diff_files: - path = item.get("path", "") - pure = pathlib.PurePosixPath(path) - changed_meta[path] = { - "functions": [], - "stem": pure.stem, - "dir": str(pure.parent) if str(pure.parent) != "." else "", - "dir_base": pure.parent.name if str(pure.parent) != "." else "", - "path_no_ext": str(pure.with_suffix("")), - } - -for item in deps_files: - path = item.get("path", "") - pure = pathlib.PurePosixPath(path) - changed_meta[path] = { - "functions": item.get("functions", []), - "stem": pure.stem, - "dir": str(pure.parent) if str(pure.parent) != "." else "", - "dir_base": pure.parent.name if str(pure.parent) != "." else "", - "path_no_ext": str(pure.with_suffix("")), - } - -def unique_terms(via): - meta = changed_meta.get(via, {}) - terms = [] - seen = set() - for fn in sorted(meta.get("functions", []), key=lambda v: (-len(v), v)): - if fn and fn not in seen: - terms.append((fn, "symbol")) - seen.add(fn) - for value, kind in [ - (meta.get("path_no_ext", ""), "path"), - (meta.get("dir", ""), "path"), - (meta.get("dir_base", ""), "identifier"), - (meta.get("stem", ""), "identifier"), - ]: - if value and value not in seen: - terms.append((value, kind)) - seen.add(value) - return terms - -def make_excerpt(lines, index): - start = max(0, index - snippet_radius) - end = min(len(lines), index + snippet_radius + 1) - excerpt = [] - for lineno in range(start, end): - excerpt.append(f"{lineno + 1:4d} | {lines[lineno]}") - text = "\n".join(excerpt) - if len(text) > max_snippet_chars: - text = text[:max_snippet_chars].rstrip() + "\n... [truncated]" - return text - -def find_snippet(target_path, via, category, reason): - abs_path = root / target_path - if not abs_path.is_file(): - return None - - try: - content = abs_path.read_text(encoding="utf-8") - except UnicodeDecodeError: - content = abs_path.read_text(encoding="utf-8", errors="replace") - - lines = content.splitlines() - if not lines: - return None - - terms = unique_terms(via) - for term, kind in terms: - if kind == "symbol": - pattern = re.compile(r"\b" + re.escape(term) + r"\b") - for idx, line in enumerate(lines): - if pattern.search(line): - return { - "category": category, - "path": target_path, - "via": via, - "reason": reason, - "matched_term": term, - "match_kind": kind, - "language": lang_map.get(pathlib.PurePosixPath(target_path).suffix, "text"), - "excerpt": make_excerpt(lines, idx), - } - else: - for idx, line in enumerate(lines): - if term in line: - return { - "category": category, - "path": target_path, - "via": via, - "reason": reason, - "matched_term": term, - "match_kind": kind, - "language": lang_map.get(pathlib.PurePosixPath(target_path).suffix, "text"), - "excerpt": make_excerpt(lines, idx), - } - return None - -snippets = [] -per_via_counts = {} -for item in impacted: - if len(snippets) >= max_snippets: - break - if per_via_counts.get(item["via"], 0) >= max_snippets_per_changed: - continue - via = item["via"] - snippet = find_snippet( - item["path"], - via, - "impacted_outside_diff", - f"depends on changed file {via}", - ) - if snippet: - snippets.append(snippet) - per_via_counts[via] = per_via_counts.get(via, 0) + 1 - -for item in context: - if len(snippets) >= max_snippets: - break - if per_via_counts.get(item["via"], 0) >= max_snippets_per_changed: - continue - via = item["via"] - snippet = find_snippet( - item["path"], - via, - "dependency_context_outside_diff", - f"reachable from changed file {via}", - ) - if snippet: - snippets.append(snippet) - per_via_counts[via] = per_via_counts.get(via, 0) + 1 - -json.dump(snippets, sys.stdout) -' - )" -fi - -if [[ "$format" == "json" ]]; then - diff_text="$(capture_codemap_block "$max_diff_chars" "diff" --diff --ref "$ref" "$abs_root")" - deps_text="$(capture_codemap_block "$max_deps_chars" "deps" --deps --diff --ref "$ref" "$abs_root")" - importers_rendered='[]' - importer_budget="$max_importers_chars" - importer_count=0 - for file in "${changed_files[@]}"; do - [[ -n "$file" ]] || continue - if (( importer_count >= max_importer_files )); then - break - fi - if (( importer_budget <= 0 )); then - break - fi - per_file_budget="$(min_int "$importer_budget" 1200)" - text="$(capture_codemap_block "$per_file_budget" "importers:$file" --importers "$file" "$abs_root")" - importers_rendered="$(jq -c --arg file "$file" --arg text "$text" '. + [{file: $file, text: $text}]' <<<"$importers_rendered")" - importer_budget=$((importer_budget - ${#text})) - importer_count=$((importer_count + 1)) - done - - jq -n \ - --arg root "$abs_root" \ - --arg ref "$ref" \ - --argjson diff "$diff_json_capped" \ - --argjson deps "$deps_json_capped" \ - --argjson importers "$importers_json" \ - --argjson summary "$summary_json" \ - --argjson impacted "$impacted_json" \ - --argjson context "$context_json" \ - --argjson snippets "$snippets_json" \ - --argjson max_affected "$max_affected" \ - --argjson max_context "$max_context" \ - --argjson max_snippets "$max_snippets" \ - --argjson max_snippets_per_changed "$max_snippets_per_changed" \ - --argjson snippet_radius "$snippet_radius" \ - --argjson max_snippet_chars "$max_snippet_chars" \ - --argjson max_total_chars "$max_total_chars" \ - --argjson max_diff_chars "$max_diff_chars" \ - --argjson max_deps_chars "$max_deps_chars" \ - --argjson max_importers_chars "$max_importers_chars" \ - --argjson max_changed_files "$max_changed_files" \ - --argjson max_importer_files "$max_importer_files" \ - --argjson max_importers_per_file "$max_importers_per_file" \ - --arg diff_text "$diff_text" \ - --arg deps_text "$deps_text" \ - --argjson importers_rendered "$importers_rendered" \ - '{ - root: $root, - ref: $ref, - summary: $summary, - diff: $diff, - deps: $deps, - importers: $importers, - limits: { - max_affected: $max_affected, - max_context: $max_context, - max_snippets: $max_snippets, - max_snippets_per_changed: $max_snippets_per_changed, - snippet_radius: $snippet_radius, - max_snippet_chars: $max_snippet_chars, - max_total_chars: $max_total_chars, - max_diff_chars: $max_diff_chars, - max_deps_chars: $max_deps_chars, - max_importers_chars: $max_importers_chars, - max_changed_files: $max_changed_files, - max_importer_files: $max_importer_files, - max_importers_per_file: $max_importers_per_file - }, - impacted_outside_diff: $impacted, - dependency_context_outside_diff: $context, - snippets: $snippets, - rendered: { - diff: $diff_text, - deps: $deps_text, - importers: $importers_rendered - } - }' - exit 0 -fi - -output="" -remaining_chars="$max_total_chars" - -append_block() { - local text="$1" - local label="$2" - if (( remaining_chars <= 0 )); then - return 1 - fi - if ((${#text} <= remaining_chars)); then - output+="$text" - remaining_chars=$((remaining_chars - ${#text})) - return 0 - fi - local marker - marker=$'\n... ['"$label"' omitted after total budget '"$max_total_chars"$' chars]\n' - local keep_chars=$((remaining_chars - ${#marker})) - if (( keep_chars < 0 )); then - keep_chars=0 - fi - output+="${text:0:keep_chars}${marker}" - remaining_chars=0 - return 1 -} - -if [[ "$format" == "markdown" ]]; then - summary_block="# Codemap Blast Radius"$'\n\n' - summary_block+="- Root: \`$abs_root\`"$'\n' - summary_block+="- Base ref: \`$ref\`"$'\n\n' - summary_block+="## Summary"$'\n\n' - summary_block+="- Changed files: $(jq -r '.changed_files' <<<"$summary_json") shown of $(jq -r '.changed_files_total' <<<"$summary_json")"$'\n' - summary_block+="- Changed files with direct dependents: $(jq -r '.files_with_dependents' <<<"$summary_json")"$'\n' - summary_block+="- Affected files outside diff: $(jq -r '.impacted_outside_diff_shown' <<<"$summary_json") shown of $(jq -r '.impacted_outside_diff_total' <<<"$summary_json")"$'\n' - summary_block+="- Dependency context outside diff: $(jq -r '.dependency_context_outside_diff_shown' <<<"$summary_json") shown of $(jq -r '.dependency_context_outside_diff_total' <<<"$summary_json")"$'\n' - if [[ "$(jq -r '.highest_blast_radius != null' <<<"$summary_json")" == "true" ]]; then - summary_block+="- Highest blast radius: \`$(jq -r '.highest_blast_radius.file' <<<"$summary_json")\` ($(jq -r '.highest_blast_radius.importer_count' <<<"$summary_json") direct dependents)"$'\n' - fi - summary_block+="- Output budgets: total ${max_total_chars} chars, diff ${max_diff_chars}, deps ${max_deps_chars}, importers ${max_importers_chars}"$'\n' - summary_block+="- Snippet limits: ${max_snippets} total, ${max_snippets_per_changed} per changed file, ${max_snippet_chars} chars max"$'\n\n' - append_block "$summary_block" "summary" || { printf '%s' "$output"; exit 0; } - - if [[ "$(jq 'length' <<<"$impacted_json")" -gt 0 ]]; then - affected_block="## Affected Outside Diff"$'\n\n' - affected_block+="$(jq -r '.[] | "- `\(.path)` depends on changed file `\(.via)`\((if .via_is_hub then " [hub, \(.via_importer_count) dependents]" else "" end))"' <<<"$impacted_json")"$'\n\n' - append_block "$affected_block" "affected outside diff" || { printf '%s' "$output"; exit 0; } - fi - - if [[ "$(jq 'length' <<<"$context_json")" -gt 0 ]]; then - context_block="## Dependency Context Outside Diff"$'\n\n' - context_block+="$(jq -r '.[] | "- changed file `\(.via)` reaches `\(.path)`\((if .relation == "shared_hub_dependency" then " [shared hub]" else "" end))"' <<<"$context_json")"$'\n\n' - append_block "$context_block" "dependency context" || { printf '%s' "$output"; exit 0; } - fi - - if [[ "$(jq 'length' <<<"$snippets_json")" -gt 0 ]]; then - snippets_block="## Impact Snippets"$'\n' - while IFS= read -r snippet; do - [[ -n "$snippet" ]] || continue - path="$(jq -r '.path' <<<"$snippet")" - via="$(jq -r '.via' <<<"$snippet")" - reason="$(jq -r '.reason' <<<"$snippet")" - matched_term="$(jq -r '.matched_term' <<<"$snippet")" - match_kind="$(jq -r '.match_kind' <<<"$snippet")" - language="$(jq -r '.language' <<<"$snippet")" - excerpt="$(jq -r '.excerpt' <<<"$snippet")" - snippets_block+=$'\n'"### \`$path\` via \`$via\`"$'\n\n' - snippets_block+="- Reason: $reason"$'\n' - snippets_block+="- Match: \`$matched_term\` ($match_kind)"$'\n\n' - snippets_block+="\`\`\`$language"$'\n'"$excerpt"$'\n'"\`\`\`"$'\n' - done < <(jq -c '.[]' <<<"$snippets_json") - snippets_block+=$'\n' - append_block "$snippets_block" "impact snippets" || { printf '%s' "$output"; exit 0; } - fi - - diff_block="## Diff"$'\n\n```text\n' - diff_block+="$(capture_codemap_block "$max_diff_chars" "diff" --diff --ref "$ref" "$abs_root")" - diff_block+=$'\n```\n\n' - append_block "$diff_block" "diff section" || { printf '%s' "$output"; exit 0; } - - deps_block="## Dependency Flow (Changed Files)"$'\n\n```text\n' - deps_block+="$(capture_codemap_block "$max_deps_chars" "deps" --deps --diff --ref "$ref" "$abs_root")" - deps_block+=$'\n```\n' - append_block "$deps_block" "deps section" || { printf '%s' "$output"; exit 0; } - - if ((${#changed_files[@]} > 0)); then - importers_block=$'\n## Importers\n' - importer_budget="$max_importers_chars" - importer_count=0 - for file in "${changed_files[@]}"; do - [[ -n "$file" ]] || continue - if (( importer_count >= max_importer_files )); then - importers_block+=$'\n... [additional importer sections omitted]\n' - break - fi - if (( importer_budget <= 0 )); then - importers_block+=$'\n... [importer budget exhausted]\n' - break - fi - per_file_budget="$(min_int "$importer_budget" 1200)" - text="$(capture_codemap_block "$per_file_budget" "importers:$file" --importers "$file" "$abs_root")" - importers_block+=$'\n'"### \`$file\`"$'\n\n```text\n'"$text"$'\n```\n' - importer_budget=$((importer_budget - ${#text})) - importer_count=$((importer_count + 1)) - done - append_block "$importers_block" "importers section" || { printf '%s' "$output"; exit 0; } - fi - - printf '%s' "$output" - exit 0 -fi - -summary_block="CODEMAP BLAST RADIUS"$'\n' -summary_block+="root=$abs_root"$'\n' -summary_block+="ref=$ref"$'\n\n' -summary_block+="[summary]"$'\n' -summary_block+="changed_files=$(jq -r '.changed_files' <<<"$summary_json")/$(jq -r '.changed_files_total' <<<"$summary_json")"$'\n' -summary_block+="files_with_dependents=$(jq -r '.files_with_dependents' <<<"$summary_json")"$'\n' -summary_block+="impacted_outside_diff=$(jq -r '.impacted_outside_diff_shown' <<<"$summary_json")/$(jq -r '.impacted_outside_diff_total' <<<"$summary_json")"$'\n' -summary_block+="dependency_context_outside_diff=$(jq -r '.dependency_context_outside_diff_shown' <<<"$summary_json")/$(jq -r '.dependency_context_outside_diff_total' <<<"$summary_json")"$'\n' -summary_block+="output_budgets=${max_total_chars}_total,${max_diff_chars}_diff,${max_deps_chars}_deps,${max_importers_chars}_importers"$'\n' -summary_block+="snippet_limits=${max_snippets}_total,${max_snippets_per_changed}_per_changed,${max_snippet_chars}_chars"$'\n\n' -append_block "$summary_block" "summary" || { printf '%s' "$output"; exit 0; } - -if [[ "$(jq 'length' <<<"$impacted_json")" -gt 0 ]]; then - affected_block='[affected_outside_diff]'$'\n' - affected_block+="$(jq -r '.[] | "\(.path) <= \(.via)"' <<<"$impacted_json")"$'\n\n' - append_block "$affected_block" "affected outside diff" || { printf '%s' "$output"; exit 0; } -fi - -if [[ "$(jq 'length' <<<"$context_json")" -gt 0 ]]; then - context_block='[dependency_context_outside_diff]'$'\n' - context_block+="$(jq -r '.[] | "\(.via) => \(.path)\((if .relation == "shared_hub_dependency" then " [shared hub]" else "" end))"' <<<"$context_json")"$'\n\n' - append_block "$context_block" "dependency context" || { printf '%s' "$output"; exit 0; } -fi - -if [[ "$(jq 'length' <<<"$snippets_json")" -gt 0 ]]; then - snippets_block='[impact_snippets]'$'\n' - while IFS= read -r snippet; do - [[ -n "$snippet" ]] || continue - path="$(jq -r '.path' <<<"$snippet")" - via="$(jq -r '.via' <<<"$snippet")" - matched_term="$(jq -r '.matched_term' <<<"$snippet")" - excerpt="$(jq -r '.excerpt' <<<"$snippet")" - snippets_block+=$'\n'"$path <= $via [$matched_term]"$'\n'"$excerpt"$'\n' - done < <(jq -c '.[]' <<<"$snippets_json") - snippets_block+=$'\n' - append_block "$snippets_block" "impact snippets" || { printf '%s' "$output"; exit 0; } -fi - -diff_block='[diff]'$'\n' -diff_block+="$(capture_codemap_block "$max_diff_chars" "diff" --diff --ref "$ref" "$abs_root")"$'\n' -append_block "$diff_block" "diff section" || { printf '%s' "$output"; exit 0; } - -deps_block='[deps]'$'\n' -deps_block+="$(capture_codemap_block "$max_deps_chars" "deps" --deps --diff --ref "$ref" "$abs_root")"$'\n' -append_block "$deps_block" "deps section" || { printf '%s' "$output"; exit 0; } - -if ((${#changed_files[@]} > 0)); then - importers_block="" - importer_budget="$max_importers_chars" - importer_count=0 - for file in "${changed_files[@]}"; do - [[ -n "$file" ]] || continue - if (( importer_count >= max_importer_files )); then - importers_block+=$'\n... [additional importer sections omitted]\n' - break - fi - if (( importer_budget <= 0 )); then - importers_block+=$'\n... [importer budget exhausted]\n' - break - fi - per_file_budget="$(min_int "$importer_budget" 1200)" - text="$(capture_codemap_block "$per_file_budget" "importers:$file" --importers "$file" "$abs_root")" - importers_block+=$'\n'"[importers] $file"$'\n'"$text"$'\n' - importer_budget=$((importer_budget - ${#text})) - importer_count=$((importer_count + 1)) - done - append_block "$importers_block" "importers section" || { printf '%s' "$output"; exit 0; } -fi - -printf '%s' "$output" From 99ee06bf9a949800df43aa5fc2df0f07631343b4 Mon Sep 17 00:00:00 2001 From: Jordan Coin Jackson Date: Wed, 8 Apr 2026 15:15:11 -0400 Subject: [PATCH 2/2] address blast-radius review feedback --- blast_radius.go | 131 ++++++++++++++++++++++++++++--------------- blast_radius_test.go | 40 +++++++++++++ 2 files changed, 127 insertions(+), 44 deletions(-) diff --git a/blast_radius.go b/blast_radius.go index faefca7..54599a2 100644 --- a/blast_radius.go +++ b/blast_radius.go @@ -160,6 +160,12 @@ type blastOutputBuilder struct { } func runBlastRadiusSubcommand(args []string) { + if code := executeBlastRadiusSubcommand(args); code != 0 { + os.Exit(code) + } +} + +func executeBlastRadiusSubcommand(args []string) int { limits := defaultBlastRadiusLimits() fs := flag.NewFlagSet("blast-radius", flag.ContinueOnError) fs.SetOutput(os.Stderr) @@ -170,8 +176,8 @@ func runBlastRadiusSubcommand(args []string) { var help bool ref := fs.String("ref", "main", "Branch/ref to compare against") fs.BoolVar(&jsonMode, "json", false, "Emit a single JSON object") - fs.BoolVar(&markdownMode, "markdown", false, "Emit Markdown output (default)") - fs.BoolVar(&markdownMode, "md", false, "Emit Markdown output (default)") + fs.BoolVar(&markdownMode, "markdown", false, "Emit Markdown output") + fs.BoolVar(&markdownMode, "md", false, "Emit Markdown output") fs.BoolVar(&textMode, "text", false, "Emit plain text output") fs.BoolVar(&help, "help", false, "Show blast-radius help") fs.BoolVar(&help, "h", false, "Show blast-radius help") @@ -194,25 +200,25 @@ func runBlastRadiusSubcommand(args []string) { if err := fs.Parse(args); err != nil { if errors.Is(err, flag.ErrHelp) { - return + return 0 } - os.Exit(2) + return 2 } if help { fs.Usage() - return + return 0 } if fs.NArg() > 1 { fmt.Fprintln(os.Stderr, "Usage: codemap blast-radius [--json|--markdown|--text] [--ref ] [path]") - os.Exit(2) + return 2 } format, err := chooseBlastRadiusFormat(jsonMode, markdownMode, textMode) if err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) - os.Exit(2) + return 2 } root := "." @@ -224,7 +230,7 @@ func runBlastRadiusSubcommand(args []string) { absRoot, cleanup, err := resolveBlastRadiusRoot(root) if err != nil { fmt.Fprintf(os.Stderr, "Error preparing root: %v\n", err) - os.Exit(1) + return 1 } defer cleanup() @@ -235,7 +241,7 @@ func runBlastRadiusSubcommand(args []string) { } else { fmt.Fprintf(os.Stderr, "Error building blast radius: %v\n", err) } - os.Exit(1) + return 1 } switch format { @@ -248,35 +254,41 @@ func runBlastRadiusSubcommand(args []string) { default: fmt.Print(renderBlastRadiusMarkdown(bundle)) } + return 0 } func printBlastRadiusUsage(fs *flag.FlagSet) { - fmt.Println("codemap blast-radius - Build a compact, bounded blast-radius bundle") - fmt.Println() - fmt.Println("Usage:") - fmt.Println(" codemap blast-radius [--json|--markdown|--text] [--ref ] [path]") - fmt.Println() - fmt.Println("Examples:") - fmt.Println(" codemap blast-radius --ref main .") - fmt.Println(" codemap blast-radius --json --ref develop /path/to/repo") - fmt.Println() - fmt.Println("Flags:") + out := fs.Output() + if out == nil { + out = os.Stderr + } + + fmt.Fprintln(out, "codemap blast-radius - Build a compact, bounded blast-radius bundle") + fmt.Fprintln(out) + fmt.Fprintln(out, "Usage:") + fmt.Fprintln(out, " codemap blast-radius [--json|--markdown|--text] [--ref ] [path]") + fmt.Fprintln(out) + fmt.Fprintln(out, "Examples:") + fmt.Fprintln(out, " codemap blast-radius --ref main .") + fmt.Fprintln(out, " codemap blast-radius --json --ref develop /path/to/repo") + fmt.Fprintln(out) + fmt.Fprintln(out, "Flags:") fs.PrintDefaults() - fmt.Println() - fmt.Println("Environment overrides:") - fmt.Println(" CODEMAP_BLAST_MAX_TOTAL_CHARS") - fmt.Println(" CODEMAP_BLAST_MAX_CHANGED_FILES") - fmt.Println(" CODEMAP_BLAST_MAX_AFFECTED") - fmt.Println(" CODEMAP_BLAST_MAX_CONTEXT") - fmt.Println(" CODEMAP_BLAST_MAX_SNIPPETS") - fmt.Println(" CODEMAP_BLAST_MAX_SNIPPETS_PER_CHANGED") - fmt.Println(" CODEMAP_BLAST_SNIPPET_RADIUS") - fmt.Println(" CODEMAP_BLAST_MAX_SNIPPET_CHARS") - fmt.Println(" CODEMAP_BLAST_MAX_DIFF_CHARS") - fmt.Println(" CODEMAP_BLAST_MAX_DEPS_CHARS") - fmt.Println(" CODEMAP_BLAST_MAX_IMPORTERS_CHARS") - fmt.Println(" CODEMAP_BLAST_MAX_IMPORTER_FILES") - fmt.Println(" CODEMAP_BLAST_MAX_IMPORTERS_PER_FILE") + fmt.Fprintln(out) + fmt.Fprintln(out, "Environment overrides:") + fmt.Fprintln(out, " CODEMAP_BLAST_MAX_TOTAL_CHARS") + fmt.Fprintln(out, " CODEMAP_BLAST_MAX_CHANGED_FILES") + fmt.Fprintln(out, " CODEMAP_BLAST_MAX_AFFECTED") + fmt.Fprintln(out, " CODEMAP_BLAST_MAX_CONTEXT") + fmt.Fprintln(out, " CODEMAP_BLAST_MAX_SNIPPETS") + fmt.Fprintln(out, " CODEMAP_BLAST_MAX_SNIPPETS_PER_CHANGED") + fmt.Fprintln(out, " CODEMAP_BLAST_SNIPPET_RADIUS") + fmt.Fprintln(out, " CODEMAP_BLAST_MAX_SNIPPET_CHARS") + fmt.Fprintln(out, " CODEMAP_BLAST_MAX_DIFF_CHARS") + fmt.Fprintln(out, " CODEMAP_BLAST_MAX_DEPS_CHARS") + fmt.Fprintln(out, " CODEMAP_BLAST_MAX_IMPORTERS_CHARS") + fmt.Fprintln(out, " CODEMAP_BLAST_MAX_IMPORTER_FILES") + fmt.Fprintln(out, " CODEMAP_BLAST_MAX_IMPORTERS_PER_FILE") } func chooseBlastRadiusFormat(jsonMode, markdownMode, textMode bool) (blastRadiusFormat, error) { @@ -379,14 +391,18 @@ func clampBlastRadiusLimits(limits blastRadiusLimits) blastRadiusLimits { func resolveBlastRadiusRoot(root string) (string, func(), error) { cleanup := func() {} _, localErr := os.Stat(root) - if isGitHubURL(root) && localErr != nil { - repoName := extractRepoName(root) - tempDir, err := cloneRepo(root, repoName) - if err != nil { - return "", cleanup, err + if isGitHubURL(root) { + if os.IsNotExist(localErr) { + repoName := extractRepoName(root) + tempDir, err := cloneRepo(root, repoName) + if err != nil { + return "", cleanup, err + } + cleanup = func() { _ = os.RemoveAll(tempDir) } + root = tempDir + } else if localErr != nil { + return "", cleanup, localErr } - cleanup = func() { _ = os.RemoveAll(tempDir) } - root = tempDir } absRoot, err := filepath.Abs(root) @@ -836,6 +852,7 @@ func findBlastRadiusSnippet(root, targetPath, via, category, reason string, chan type blastSnippetTerm struct { Value string Kind string + Regex *regexp.Regexp } func blastSnippetTerms(meta blastChangedMeta) []blastSnippetTerm { @@ -856,7 +873,11 @@ func blastSnippetTerms(meta blastChangedMeta) []blastSnippetTerm { continue } seen[fn] = true - terms = append(terms, blastSnippetTerm{Value: fn, Kind: "symbol"}) + terms = append(terms, blastSnippetTerm{ + Value: fn, + Kind: "symbol", + Regex: regexp.MustCompile(`\b` + regexp.QuoteMeta(fn) + `\b`), + }) } for _, candidate := range []blastSnippetTerm{ @@ -879,8 +900,7 @@ func blastSnippetTerms(meta blastChangedMeta) []blastSnippetTerm { func blastLineMatchesTerm(line string, term blastSnippetTerm) bool { switch term.Kind { case "symbol": - pattern := regexp.MustCompile(`\b` + regexp.QuoteMeta(term.Value) + `\b`) - return pattern.MatchString(line) + return term.Regex != nil && term.Regex.MatchString(line) default: return strings.Contains(line, term.Value) } @@ -908,6 +928,17 @@ func buildBlastSnippetExcerpt(lines []string, index, radius, maxChars int) strin } func buildBlastRadiusRendered(diffProject scanner.Project, depsProject scanner.DepsProject, reports []scanner.ImportersReport, limits blastRadiusLimits) blastRadiusRendered { + if len(diffProject.Files) == 0 { + ref := diffProject.DiffRef + if ref == "" { + ref = "main" + } + return blastRadiusRendered{ + Diff: fmt.Sprintf("No files changed vs %s\n", ref), + Deps: "No changed source files to analyze.\n", + } + } + rendered := blastRadiusRendered{ Diff: truncateBlastRadiusText(renderDiffProject(diffProject), limits.MaxDiffChars, "diff"), Deps: truncateBlastRadiusText(renderDepsProject(depsProject), limits.MaxDepsChars, "deps"), @@ -951,6 +982,12 @@ func renderBlastRadiusMarkdown(bundle blastRadiusBundle) string { return builder.String() } + if bundle.Summary.ChangedFilesTotal == 0 { + empty := fmt.Sprintf("## No Changes\n\n- No files changed vs `%s`.\n- Dependency, snippet, and importer sections are omitted.\n", bundle.Ref) + builder.Append(empty, "no changes") + return builder.String() + } + if len(bundle.ImpactedOutsideDiff) > 0 { var section strings.Builder section.WriteString("## Affected Outside Diff\n\n") @@ -1044,6 +1081,12 @@ func renderBlastRadiusText(bundle blastRadiusBundle) string { return builder.String() } + if bundle.Summary.ChangedFilesTotal == 0 { + empty := fmt.Sprintf("[no_changes]\nNo files changed vs %s.\nDependency, snippet, and importer sections are omitted.\n", bundle.Ref) + builder.Append(empty, "no changes") + return builder.String() + } + if len(bundle.ImpactedOutsideDiff) > 0 { var section strings.Builder section.WriteString("[affected_outside_diff]\n") diff --git a/blast_radius_test.go b/blast_radius_test.go index 3f28119..5e3f5fb 100644 --- a/blast_radius_test.go +++ b/blast_radius_test.go @@ -127,3 +127,43 @@ func TestBlastRadiusSubcommandMarkdownAndJSON(t *testing.T) { t.Fatalf("expected snippet match to target ComputeTotal, got %+v", bundle.Snippets[0]) } } + +func TestBlastRadiusSubcommandNoChanges(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not available") + } + + root := makeMainGitRepo(t, "main") + + markdown, stderr, err := runCodemapWithInput("", "blast-radius", "--ref", "HEAD", root) + if err != nil { + t.Fatalf("blast-radius no-changes markdown failed: %v\nstderr=%s", err, stderr) + } + if !strings.Contains(markdown, "## No Changes") { + t.Fatalf("expected no-changes section in markdown output, got:\n%s", markdown) + } + if !strings.Contains(markdown, "No files changed vs `HEAD`.") { + t.Fatalf("expected no-changes message in markdown output, got:\n%s", markdown) + } + if strings.Contains(markdown, "## Diff") { + t.Fatalf("expected no diff section when there are no changes, got:\n%s", markdown) + } + + jsonOut, stderr, err := runCodemapWithInput("", "blast-radius", "--json", "--ref", "HEAD", root) + if err != nil { + t.Fatalf("blast-radius no-changes json failed: %v\nstderr=%s", err, stderr) + } + var bundle blastRadiusBundle + if err := json.Unmarshal([]byte(jsonOut), &bundle); err != nil { + t.Fatalf("expected no-changes blast-radius JSON output, got error %v with body:\n%s", err, jsonOut) + } + if bundle.Summary.ChangedFiles != 0 || bundle.Summary.ChangedFilesTotal != 0 { + t.Fatalf("expected zero changed files in no-changes output, got %+v", bundle.Summary) + } + if bundle.Rendered.Diff != "No files changed vs HEAD\n" { + t.Fatalf("unexpected no-changes diff text: %q", bundle.Rendered.Diff) + } + if bundle.Rendered.Deps != "No changed source files to analyze.\n" { + t.Fatalf("unexpected no-changes deps text: %q", bundle.Rendered.Deps) + } +}