diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2fffd9a..356e266 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -101,7 +101,7 @@ jobs: with: distribution: goreleaser version: latest - args: release ${{ (github.event_name == 'workflow_dispatch' && inputs.dry_run) && '--snapshot --clean' || '--clean' }} + args: release --config .goreleaser.full.yml ${{ (github.event_name == 'workflow_dispatch' && inputs.dry_run) && '--snapshot --clean' || '--clean' }} env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} HOMEBREW_TAP_TOKEN: ${{ secrets.SCOOP_TOKEN }} diff --git a/.gitignore b/.gitignore index 9a9bd2c..f60dd28 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,7 @@ __pycache__/ .codemap/ /codemap-dev /dev_codemap +/bundled-tools/ firebase-debug.log firebalse-debug.log coverage.out diff --git a/.goreleaser.full.yml b/.goreleaser.full.yml new file mode 100644 index 0000000..2c82a96 --- /dev/null +++ b/.goreleaser.full.yml @@ -0,0 +1,146 @@ +version: 2 + +project_name: codemap + +before: + hooks: + - go mod tidy + - ./scripts/download-bundled-astgrep.sh + +builds: + - id: codemap + main: . + binary: codemap + env: + - CGO_ENABLED=0 + ldflags: + - -s -w + goos: + - darwin + - linux + - windows + goarch: + - amd64 + - arm64 + ignore: + - goos: windows + goarch: arm64 + +archives: + - id: default + ids: + - codemap + formats: + - tar.gz + format_overrides: + - goos: windows + formats: + - zip + files: + - README.md + - LICENSE* + - src: scanner/sg-rules/* + dst: sg-rules + - id: full + ids: + - codemap + name_template: '{{ .ProjectName }}-full_{{ .Version }}_{{ .Os }}_{{ .Arch }}{{ with .Arm }}v{{ . }}{{ end }}{{ with .Mips }}_{{ . }}{{ end }}{{ if not (eq .Amd64 "v1") }}{{ .Amd64 }}{{ end }}' + formats: + - tar.gz + format_overrides: + - goos: windows + formats: + - zip + files: + - README.md + - LICENSE* + - src: scanner/sg-rules/* + dst: sg-rules + - src: bundled-tools/{{ .Os }}_{{ .Arch }}/ast-grep{{ if eq .Os "windows" }}.exe{{ end }} + strip_parent: true + - src: bundled-tools/{{ .Os }}_{{ .Arch }}/sg{{ if eq .Os "windows" }}.exe{{ end }} + strip_parent: true + +checksum: + name_template: 'checksums.txt' + +changelog: + sort: asc + filters: + exclude: + - '^docs:' + - '^test:' + - '^chore:' + +release: + github: + owner: JordanCoin + name: codemap + +brews: + - repository: + owner: JordanCoin + name: homebrew-tap + token: "{{ .Env.HOMEBREW_TAP_TOKEN }}" + ids: + - default + directory: . + homepage: https://github.com/JordanCoin/codemap + description: Generate a brain map of your codebase for LLM context + license: MIT + dependencies: + - name: ast-grep + install: | + bin.install "codemap" + (pkgshare/"sg-rules").install Dir["sg-rules/*.yml"] if Dir.exist?("sg-rules") + caveats: | + The --deps mode uses ast-grep for code analysis. + test: | + system "#{bin}/codemap", "--help" + +scoops: + - repository: + owner: JordanCoin + name: scoop-codemap + token: "{{ .Env.SCOOP_TOKEN }}" + ids: + - default + homepage: https://github.com/JordanCoin/codemap + description: Generate a brain map of your codebase for LLM context + license: MIT + depends: + - ast-grep + +winget: + - name: codemap + ids: + - default + publisher: JordanCoin + short_description: Generate a brain map of your codebase for LLM context + license: MIT + publisher_url: https://github.com/JordanCoin + publisher_support_url: https://github.com/JordanCoin/codemap/issues + package_identifier: JordanCoin.codemap + homepage: https://github.com/JordanCoin/codemap + description: | + codemap generates a compact, structured "brain map" of your codebase + that LLMs can instantly understand. One command gives instant + architectural context without burning tokens. + license_url: https://github.com/JordanCoin/codemap/blob/main/LICENSE + tags: + - cli + - developer-tools + - ai + - llm + - code-analysis + repository: + owner: JordanCoin + name: winget-pkgs + branch: "JordanCoin.codemap-{{.Version}}" + token: "{{ .Env.SCOOP_TOKEN }}" + pull_request: + enabled: true + base: + owner: microsoft + name: winget-pkgs + branch: master diff --git a/.goreleaser.yml b/.goreleaser.yml index e9d75d2..d176ffb 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -27,6 +27,8 @@ builds: archives: - id: default + ids: + - codemap formats: - tar.gz format_overrides: diff --git a/README.md b/README.md index 85befbd..2546f77 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,40 @@ scoop install codemap > Other options: [Releases](https://github.com/JordanCoin/codemap/releases) | `go install` | Build from source +## Tarball / CI Install + +If you install `codemap` from a release tarball, also install `ast-grep` separately for `--deps`. +The tarball includes `codemap` and the bundled rules, but not the `ast-grep` executable. + +Example for Alpine-based CI: + +```bash +apk add --no-cache curl jq bash python3 py3-pip + +ARCH=$(uname -m) +if [ "$ARCH" = "x86_64" ]; then ARCH="amd64"; elif [ "$ARCH" = "aarch64" ]; then ARCH="arm64"; fi + +CODEMAP_VERSION=$(curl -fsSL https://api.github.com/repos/JordanCoin/codemap/releases/latest | jq -r '.tag_name' | tr -d 'v') +curl -fsSL "https://github.com/JordanCoin/codemap/releases/download/v${CODEMAP_VERSION}/codemap_${CODEMAP_VERSION}_linux_${ARCH}.tar.gz" \ + | tar xz -C /usr/local/bin/ codemap + +python3 -m pip install --no-cache-dir ast-grep-cli +``` + +If you want a self-contained archive for CI/CD, use the `codemap-full` release artifact instead. +It includes `codemap`, `ast-grep`, and `sg` in one archive so `--deps` works after extraction. + +```bash +apk add --no-cache curl jq bash + +ARCH=$(uname -m) +if [ "$ARCH" = "x86_64" ]; then ARCH="amd64"; elif [ "$ARCH" = "aarch64" ]; then ARCH="arm64"; fi + +CODEMAP_VERSION=$(curl -fsSL https://api.github.com/repos/JordanCoin/codemap/releases/latest | jq -r '.tag_name' | tr -d 'v') +curl -fsSL "https://github.com/JordanCoin/codemap/releases/download/v${CODEMAP_VERSION}/codemap-full_${CODEMAP_VERSION}_linux_${ARCH}.tar.gz" \ + | tar xz -C /usr/local/bin/ codemap ast-grep sg +``` + ## Recommended Setup (Hooks + Daemon + Config) No repo clone is required for normal users. @@ -182,6 +216,23 @@ Uses a shallow clone to a temp directory (fast, no history, auto-cleanup). If yo > Powered by [ast-grep](https://ast-grep.github.io/). Install via `brew install ast-grep` for `--deps` mode. +## Blast Radius Bundle + +If you want a compact review bundle for another LLM, combine the three high-signal views: + +```bash +codemap --json --diff --ref main . +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: + +```bash +bash scripts/codemap-blast-radius.sh --markdown --ref main . +bash scripts/codemap-blast-radius.sh --json --ref main . +``` + ## Claude Integration **Hooks (Recommended)** — Automatic context at session start, before/after edits, and more. diff --git a/go.mod b/go.mod index 217e53b..ebec3d7 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( github.com/modelcontextprotocol/go-sdk v1.1.0 github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 golang.org/x/term v0.37.0 + gopkg.in/yaml.v3 v3.0.1 ) require ( @@ -33,5 +34,4 @@ require ( golang.org/x/oauth2 v0.30.0 // indirect golang.org/x/sys v0.38.0 // indirect golang.org/x/text v0.3.8 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index e3e4d52..da4a643 100644 --- a/go.sum +++ b/go.sum @@ -68,6 +68,7 @@ golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo= golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/main.go b/main.go index 78fcc54..9e4e20a 100644 --- a/main.go +++ b/main.go @@ -305,7 +305,7 @@ func main() { // Importers mode - check file impact if *importersMode != "" { - runImportersMode(absRoot, *importersMode) + runImportersMode(absRoot, *importersMode, *jsonMode) return } @@ -407,13 +407,20 @@ func runDepsMode(absRoot, root string, jsonMode bool, diffRef string, changedFil } else { analyses, err = scanForDepsWithHint(root) if err != nil { - 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 # via Rust (installs as 'ast-grep')") - fmt.Fprintln(os.Stderr, " pipx install ast-grep # via Python (installs as 'ast-grep')") - fmt.Fprintln(os.Stderr, "") + 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.") + } else { + fmt.Fprintf(os.Stderr, "Error scanning dependencies: %v\n", err) + } os.Exit(1) } externalDeps = scanner.ReadExternalDeps(absRoot) @@ -529,11 +536,10 @@ func runWatchMode(root string, verbose bool) { fmt.Printf(" Events logged: %d\n", len(events)) } -func runImportersMode(root, file string) { +func buildImportersReport(root, file string) (scanner.ImportersReport, error) { fg, err := scanner.BuildFileGraph(root) if err != nil { - fmt.Fprintf(os.Stderr, "Error building file graph: %v\n", err) - os.Exit(1) + return scanner.ImportersReport{}, err } // Handle absolute paths - convert to relative @@ -544,8 +550,41 @@ func runImportersMode(root, file string) { } 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 +} + +func runImportersMode(root, file string, jsonMode bool) { + report, err := buildImportersReport(root, file) + if err != nil { + fmt.Fprintf(os.Stderr, "Error building file graph: %v\n", err) + os.Exit(1) + } + + if jsonMode { + _ = json.NewEncoder(os.Stdout).Encode(report) + return + } + + importers := report.Importers if len(importers) >= 3 { - fmt.Printf("⚠️ HUB FILE: %s\n", file) + 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:") @@ -557,26 +596,18 @@ func runImportersMode(root, file string) { fmt.Printf(" • %s\n", imp) } } else if len(importers) > 0 { - fmt.Printf("📍 File: %s\n", file) + 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) } } - // Also check if this file imports any hubs - imports := fg.Imports[file] - var hubImports []string - for _, imp := range imports { - if fg.IsHub(imp) { - hubImports = append(hubImports, imp) - } - } - if len(hubImports) > 0 { + if len(report.HubImports) > 0 { if len(importers) == 0 { - fmt.Printf("📍 File: %s\n", file) + fmt.Printf("📍 File: %s\n", report.File) } - fmt.Printf(" Imports %d hub(s): %s\n", len(hubImports), strings.Join(hubImports, ", ")) + fmt.Printf(" Imports %d hub(s): %s\n", len(report.HubImports), strings.Join(report.HubImports, ", ")) } } diff --git a/main_more_test.go b/main_more_test.go index 87fdb81..a355310 100644 --- a/main_more_test.go +++ b/main_more_test.go @@ -290,7 +290,7 @@ func TestRunImportersMode(t *testing.T) { writeImportersFixture(t, root) stdout, _ := captureMainStreams(t, func() { - runImportersMode(root, filepath.Join(root, "pkg", "types", "types.go")) + runImportersMode(root, filepath.Join(root, "pkg", "types", "types.go"), false) }) for _, check := range []string{"HUB FILE: pkg/types/types.go", "Imported by 4 files", "Dependents:"} { @@ -341,6 +341,21 @@ func TestRunDepsModeJSONAndMainDispatchesDepsAndImporters(t *testing.T) { if !strings.Contains(stdout, "Imports 1 hub(s): pkg/types/types.go") { t.Fatalf("expected hub import summary for main.go, got:\n%s", stdout) } + + stdout = runMainWithArgs(t, []string{"codemap", "--json", "--importers", "main.go", root}) + var importersReport scanner.ImportersReport + if err := json.Unmarshal([]byte(stdout), &importersReport); err != nil { + t.Fatalf("expected importers JSON output, got error %v with body:\n%s", err, stdout) + } + if importersReport.Mode != "importers" || importersReport.File != "main.go" { + t.Fatalf("expected importers report for main.go, got %+v", importersReport) + } + if len(importersReport.Importers) != 0 { + t.Fatalf("expected main.go to have no importers in fixture, got %+v", importersReport.Importers) + } + if len(importersReport.HubImports) != 1 || importersReport.HubImports[0] != "pkg/types/types.go" { + t.Fatalf("expected hub import summary in JSON, got %+v", importersReport.HubImports) + } } func TestRunHandoffSubcommandBuildAndDetailJSON(t *testing.T) { diff --git a/scanner/astgrep.go b/scanner/astgrep.go index 6ef305d..3deb803 100644 --- a/scanner/astgrep.go +++ b/scanner/astgrep.go @@ -9,6 +9,7 @@ import ( "os" "os/exec" "path/filepath" + "runtime" "strings" "syscall" "time" @@ -18,6 +19,7 @@ import ( var sgRules embed.FS var astGrepScanTimeout = 30 * time.Second +var ErrAstGrepNotFound = errors.New("ast-grep not found (checked bundled tools and PATH)") // ScanMatch represents a match from sg scan JSON output type ScanMatch struct { @@ -83,14 +85,90 @@ func extractJSONArray(data []byte) []byte { return data[idx:] } -// findAstGrepBinary checks for "ast-grep" first, then "sg" -// Note: Linux has a system "sg" command (setgroups), so we check ast-grep first +func isAstGrepBinary(path string) bool { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + out, err := exec.CommandContext(ctx, path, "--version").CombinedOutput() + if err != nil && len(out) == 0 { + return false + } + + return strings.Contains(strings.ToLower(string(out)), "ast-grep") +} + +func bundledAstGrepNames() []string { + if runtime.GOOS == "windows" { + return []string{"ast-grep.exe", "sg.exe"} + } + return []string{"ast-grep", "sg"} +} + +func bundledAstGrepCandidates(executablePath string) []string { + if executablePath == "" { + return nil + } + + seen := map[string]bool{} + dirs := make([]string, 0, 2) + addDir := func(dir string) { + if dir == "" { + return + } + if resolved, err := filepath.EvalSymlinks(dir); err == nil { + dir = resolved + } + if seen[dir] { + return + } + seen[dir] = true + dirs = append(dirs, dir) + } + + addDir(filepath.Dir(executablePath)) + if resolved, err := filepath.EvalSymlinks(executablePath); err == nil { + addDir(filepath.Dir(resolved)) + } + + var candidates []string + for _, dir := range dirs { + for _, name := range bundledAstGrepNames() { + candidates = append(candidates, filepath.Join(dir, name)) + } + } + return candidates +} + +func findBundledAstGrepBinary() string { + executablePath, err := os.Executable() + if err != nil { + return "" + } + + for _, candidate := range bundledAstGrepCandidates(executablePath) { + if isAstGrepBinary(candidate) { + return candidate + } + } + return "" +} + +// findAstGrepBinary checks for "ast-grep" first, then "sg". +// Linux commonly ships a non-ast-grep "sg" binary, so candidates must +// identify themselves as ast-grep before we accept them. func findAstGrepBinary() string { - if _, err := exec.LookPath("ast-grep"); err == nil { - return "ast-grep" + if bundled := findBundledAstGrepBinary(); bundled != "" { + return bundled } - if _, err := exec.LookPath("sg"); err == nil { - return "sg" + + for _, candidate := range []string{"ast-grep", "sg"} { + path, err := exec.LookPath(candidate) + if err != nil { + continue + } + if isAstGrepBinary(path) { + return path + } } return "" } diff --git a/scanner/astgrep_test.go b/scanner/astgrep_test.go index a8c970e..a74d07c 100644 --- a/scanner/astgrep_test.go +++ b/scanner/astgrep_test.go @@ -1,6 +1,7 @@ package scanner import ( + "errors" "os" "path/filepath" "runtime" @@ -8,6 +9,18 @@ import ( "time" ) +func canonicalTestPath(path string) string { + dir := filepath.Dir(path) + base := filepath.Base(path) + if resolvedDir, err := filepath.EvalSymlinks(dir); err == nil { + return filepath.Join(resolvedDir, base) + } + if resolved, err := filepath.EvalSymlinks(path); err == nil { + return resolved + } + return path +} + func TestAstGrepAnalyzer(t *testing.T) { analyzer := NewAstGrepAnalyzer() if !analyzer.Available() { @@ -201,3 +214,84 @@ func TestAstGrepScanDirectoryTimeout(t *testing.T) { t.Fatalf("expected nil results on timeout, got: %v", results) } } + +func TestScanForDepsRejectsNonAstGrepSg(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("requires shell script execution") + } + + tmpDir := t.TempDir() + fakeBinary := filepath.Join(tmpDir, "sg") + if err := os.WriteFile(fakeBinary, []byte("#!/bin/sh\necho 'setgroups utility' >&2\nexit 1\n"), 0755); err != nil { + t.Fatalf("failed to create fake sg binary: %v", err) + } + + oldPath := os.Getenv("PATH") + if err := os.Setenv("PATH", tmpDir); err != nil { + t.Fatalf("failed to set PATH: %v", err) + } + t.Cleanup(func() { + _ = os.Setenv("PATH", oldPath) + }) + + _, err := ScanForDeps(t.TempDir()) + if !errors.Is(err, ErrAstGrepNotFound) { + t.Fatalf("expected ErrAstGrepNotFound, got %v", err) + } +} + +func TestBundledAstGrepCandidates(t *testing.T) { + tmpDir := t.TempDir() + exeName := "codemap" + wantNames := []string{"ast-grep", "sg"} + if runtime.GOOS == "windows" { + exeName += ".exe" + wantNames = []string{"ast-grep.exe", "sg.exe"} + } + + exePath := filepath.Join(tmpDir, exeName) + if err := os.WriteFile(exePath, []byte(""), 0755); err != nil { + t.Fatalf("failed to create fake executable: %v", err) + } + + got := bundledAstGrepCandidates(exePath) + if len(got) != len(wantNames) { + t.Fatalf("expected %d candidates, got %d: %v", len(wantNames), len(got), got) + } + + for i, name := range wantNames { + want := canonicalTestPath(filepath.Join(tmpDir, name)) + if got[i] != want { + t.Fatalf("candidate %d: expected %q, got %q", i, want, got[i]) + } + } +} + +func TestFindBundledAstGrepBinaryPrefersSiblingAstGrep(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("requires shell script execution") + } + + tmpDir := t.TempDir() + exePath := filepath.Join(tmpDir, "codemap") + if err := os.WriteFile(exePath, []byte("#!/bin/sh\n"), 0755); err != nil { + t.Fatalf("failed to create fake codemap binary: %v", err) + } + + bundled := filepath.Join(tmpDir, "ast-grep") + if err := os.WriteFile(bundled, []byte("#!/bin/sh\necho 'ast-grep 0.42.1'\n"), 0755); err != nil { + t.Fatalf("failed to create fake bundled ast-grep: %v", err) + } + + got := "" + for _, candidate := range bundledAstGrepCandidates(exePath) { + if isAstGrepBinary(candidate) { + got = candidate + break + } + } + + if got != canonicalTestPath(bundled) { + t.Fatalf("expected bundled ast-grep %q, got %q", canonicalTestPath(bundled), got) + } +} diff --git a/scanner/types.go b/scanner/types.go index 83ec2b7..bf94346 100644 --- a/scanner/types.go +++ b/scanner/types.go @@ -48,6 +48,18 @@ type DepsProject struct { DiffRef string `json:"diff_ref,omitempty"` } +// ImportersReport is the JSON output for --importers mode. +type ImportersReport 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"` +} + // extToLang maps file extensions to language names var extToLang = map[string]string{ ".go": "go", diff --git a/scanner/walker.go b/scanner/walker.go index 05c1679..48faca6 100644 --- a/scanner/walker.go +++ b/scanner/walker.go @@ -2,7 +2,6 @@ package scanner import ( "bufio" - "fmt" "os" "path/filepath" "strings" @@ -297,7 +296,7 @@ func ScanForDeps(root string) ([]FileAnalysis, error) { defer scanner.Close() if !scanner.Available() { - return nil, fmt.Errorf("ast-grep not found in PATH (tried 'sg' and 'ast-grep')") + return nil, ErrAstGrepNotFound } return scanner.ScanDirectory(root) diff --git a/scripts/codemap-blast-radius.sh b/scripts/codemap-blast-radius.sh new file mode 100755 index 0000000..dada9c2 --- /dev/null +++ b/scripts/codemap-blast-radius.sh @@ -0,0 +1,700 @@ +#!/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" diff --git a/scripts/download-bundled-astgrep.sh b/scripts/download-bundled-astgrep.sh new file mode 100755 index 0000000..3738e8d --- /dev/null +++ b/scripts/download-bundled-astgrep.sh @@ -0,0 +1,132 @@ +#!/usr/bin/env bash + +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: download-bundled-astgrep.sh [--version ] [--output-dir ] + +Downloads pinned ast-grep release assets and extracts ast-grep/sg into: + /_/ + +Environment: + AST_GREP_VERSION Override the pinned ast-grep release version. +EOF +} + +version="${AST_GREP_VERSION:-0.42.1}" +output_dir="bundled-tools" + +while [[ $# -gt 0 ]]; do + case "$1" in + --version) + version="${2:-}" + shift 2 + ;; + --output-dir) + output_dir="${2:-}" + shift 2 + ;; + --help|-h) + usage + exit 0 + ;; + *) + echo "Unknown argument: $1" >&2 + usage >&2 + exit 1 + ;; + esac +done + +if ! command -v python3 >/dev/null 2>&1; then + echo "python3 is required to download bundled ast-grep archives" >&2 + exit 1 +fi + +targets=( + "darwin amd64 app-x86_64-apple-darwin.zip a038965bfd7fe44257c771cdf8918dc3467dd8ec0eef673b8b14f639b144cdbd" + "darwin arm64 app-aarch64-apple-darwin.zip c3961d8e8a4ee0ce2d0d98c7beeb168bb331cdc766b53630118a7b6c4fd39015" + "linux amd64 app-x86_64-unknown-linux-gnu.zip 5de8b87cba67fc8dc3e239d54b6484802ad745a7ae3de76be4fe89661dc52657" + "linux arm64 app-aarch64-unknown-linux-gnu.zip 3ba383839044cf9817929435f5ce0027f91d06931e8efb32d942e58d73d92be5" + "windows amd64 app-x86_64-pc-windows-msvc.zip fe34f631bb24c08ad146f92ca2a92971a53d179461b509fd8d32dc863bff9f83" +) + +case "$output_dir" in + ""|"/"|".") + echo "Refusing to use unsafe output directory: $output_dir" >&2 + exit 1 + ;; +esac + +for target in "${targets[@]}"; do + read -r goos goarch asset asset_sha256 <<<"$target" + dest="$output_dir/${goos}_${goarch}" + rm -rf "$dest" + mkdir -p "$dest" + + python3 - "$version" "$asset" "$asset_sha256" "$dest" <<'PY' +import hashlib +import io +import os +import shutil +import sys +import time +import urllib.error +import urllib.request +import zipfile + +DOWNLOAD_TIMEOUT_SECONDS = 30 +DOWNLOAD_RETRIES = 3 +RETRY_BACKOFF_SECONDS = 2 + +version, asset, expected_sha256, dest = sys.argv[1:] +url = f"https://github.com/ast-grep/ast-grep/releases/download/{version}/{asset}" +print(f"Downloading {url}", file=sys.stderr) + +last_error = None +for attempt in range(1, DOWNLOAD_RETRIES + 1): + try: + with urllib.request.urlopen(url, timeout=DOWNLOAD_TIMEOUT_SECONDS) as response: + data = response.read() + break + except (TimeoutError, urllib.error.URLError, OSError) as exc: + last_error = exc + if attempt == DOWNLOAD_RETRIES: + raise + print( + f"Download attempt {attempt} failed for {url}: {exc}. " + f"Retrying in {RETRY_BACKOFF_SECONDS}s...", + file=sys.stderr, + ) + time.sleep(RETRY_BACKOFF_SECONDS) +else: + raise last_error + +actual_sha256 = hashlib.sha256(data).hexdigest() +if actual_sha256 != expected_sha256: + raise SystemExit( + f"{asset} sha256 mismatch: expected {expected_sha256}, got {actual_sha256}" + ) + +required = ["ast-grep", "sg"] +if asset.endswith("windows-msvc.zip"): + required = [name + ".exe" for name in required] + +with zipfile.ZipFile(io.BytesIO(data)) as archive: + names = set(archive.namelist()) + missing = [name for name in required if name not in names] + if missing: + raise SystemExit( + f"{asset} did not contain required files: {', '.join(missing)}" + ) + + for name in required: + target = os.path.join(dest, name) + with archive.open(name) as src, open(target, "wb") as dst: + shutil.copyfileobj(src, dst) + os.chmod(target, 0o755) +PY +done + +printf 'Bundled ast-grep %s into %s\n' "$version" "$output_dir"