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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 100 additions & 3 deletions scanner/astgrep.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,13 @@ import (
"encoding/json"
"errors"
"fmt"
"io/fs"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"sync"
"syscall"
"time"

Expand Down Expand Up @@ -46,6 +48,9 @@ type AstGrepScanner struct {
rulesDir string
inlineRules string
binary string // "sg" or "ast-grep", whichever is available

mu sync.Mutex
shimRulesDir string // materialized rules for cmd.exe shims, cleaned up in Close
}

// NewAstGrepScanner creates a scanner using the embedded rules.
Expand Down Expand Up @@ -84,18 +89,96 @@ func extractJSONArray(data []byte) []byte {
return data[idx:]
}

// astGrepCommand builds the exec.Cmd for an ast-grep binary path. Windows
// cmd/bat shims (created by `npm install -g @ast-grep/cli`) cannot be
// executed by CreateProcess directly, so they are wrapped in cmd.exe /c.
func astGrepCommand(ctx context.Context, path string, args ...string) *exec.Cmd {
if runtime.GOOS == "windows" {
switch strings.ToLower(filepath.Ext(path)) {
case ".cmd", ".bat":
wrapped := append([]string{"/c", path}, args...)
return exec.CommandContext(ctx, "cmd.exe", wrapped...)
}
}
return exec.CommandContext(ctx, path, args...)
}

func isAstGrepBinary(path string) bool {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()

out, err := exec.CommandContext(ctx, path, "--version").CombinedOutput()
out, err := astGrepCommand(ctx, path, "--version").CombinedOutput()
if err != nil && len(out) == 0 {
return false
}

return strings.Contains(strings.ToLower(string(out)), "ast-grep")
}

// isWindowsCmdShim reports whether path is a Windows cmd/bat script that must
// run through cmd.exe and cannot reliably carry long multi-line arguments
// such as --inline-rules.
func isWindowsCmdShim(path string) bool {
if runtime.GOOS != "windows" {
return false
}
switch strings.ToLower(filepath.Ext(path)) {
case ".cmd", ".bat":
return true
}
return false
}

// ensureShimRules materializes the embedded rules into a temp directory once
// so the scan can pass a short --config path instead of the multi-line
// --inline-rules string, which cmd.exe shims mangle. The directory is
// removed by Close.
func (s *AstGrepScanner) ensureShimRules() (string, error) {
s.mu.Lock()
defer s.mu.Unlock()

if s.shimRulesDir != "" {
return s.shimRulesDir, nil
}

dir, err := os.MkdirTemp("", "codemap-sg-rules-")
if err != nil {
return "", err
}
if err := os.MkdirAll(filepath.Join(dir, "rules"), 0o755); err != nil {
os.RemoveAll(dir)
return "", err
}
// ast-grep does not load rules from the directory that contains
// sgconfig.yml itself (ruleDirs: ["."] is a no-op), so rules are
// materialized into a "rules" subdirectory next to a generated config.
err = fs.WalkDir(sgRules, "sg-rules", func(p string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() {
return err
}
base := filepath.Base(p)
if !strings.HasSuffix(base, ".yml") || base == "sgconfig.yml" {
return nil
}
content, readErr := sgRules.ReadFile(p)
if readErr != nil {
return readErr
}
return os.WriteFile(filepath.Join(dir, "rules", base), content, 0o644)
})
if err != nil {
os.RemoveAll(dir)
return "", err
}
sgconfig := "ruleDirs:\n - rules\n"
if err := os.WriteFile(filepath.Join(dir, "sgconfig.yml"), []byte(sgconfig), 0o644); err != nil {
os.RemoveAll(dir)
return "", err
}
s.shimRulesDir = dir
return dir, nil
}

func bundledAstGrepNames() []string {
if runtime.GOOS == "windows" {
return []string{"ast-grep.exe", "sg.exe"}
Expand Down Expand Up @@ -177,6 +260,9 @@ func (s *AstGrepScanner) Close() {
if s.rulesDir != "" {
os.RemoveAll(s.rulesDir)
}
if s.shimRulesDir != "" {
os.RemoveAll(s.shimRulesDir)
}
}

// Available checks if ast-grep CLI is available (as "sg" or "ast-grep")
Expand Down Expand Up @@ -263,7 +349,18 @@ func (s *AstGrepScanner) scanDirectory(parent context.Context, root string) ([]F

// Build command args, excluding nested git repos that ast-grep would
// treat as separate repo boundaries (ignoring parent .gitignore)
args := []string{"scan", "--inline-rules", inlineRules, "--json"}
args := []string{"scan", "--json"}
if isWindowsCmdShim(s.binary) {
// cmd.exe shims mangle long multi-line arguments, so pass a short
// --config path to materialized rules instead of --inline-rules.
rulesDir, err := s.ensureShimRules()
if err != nil {
return nil, newIncompleteScanError("ast-grep", ScanSourceFailed, fmt.Sprintf("failed to materialize rules for cmd shim: %v", err), err)
}
args = append(args, "--config", filepath.Join(rulesDir, "sgconfig.yml"))
} else {
args = append(args, "--inline-rules", inlineRules)
}
for _, repo := range findNestedGitRepos(root) {
args = append(args, "--globs", "!"+repo+"/**")
}
Expand All @@ -272,7 +369,7 @@ func (s *AstGrepScanner) scanDirectory(parent context.Context, root string) ([]F
ctx, cancel := context.WithTimeout(parent, astGrepScanTimeout)
defer cancel()

cmd := exec.CommandContext(ctx, s.binary, args...)
cmd := astGrepCommand(ctx, s.binary, args...)
cmd.WaitDelay = 100 * time.Millisecond
out, err := cmd.Output()
if err != nil {
Expand Down
57 changes: 57 additions & 0 deletions scanner/astgrep_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import (
"strings"
"testing"
"time"

"codemap/analysis"
)

func canonicalTestPath(path string) string {
Expand Down Expand Up @@ -444,3 +446,58 @@ func TestFindBundledAstGrepBinaryPrefersSiblingAstGrep(t *testing.T) {
t.Fatalf("expected bundled ast-grep %q, got %q", canonicalTestPath(bundled), got)
}
}

func TestAstGrepCommandExecutesCmdShim(t *testing.T) {
if runtime.GOOS != "windows" {
t.Skip("windows-only: cmd.exe shims")
}

tmpDir := t.TempDir()
shim := filepath.Join(tmpDir, "ast-grep.cmd")
script := "@echo ast-grep 0.45.1\n"
if err := os.WriteFile(shim, []byte(script), 0644); err != nil {
t.Fatalf("failed to create fake ast-grep.cmd shim: %v", err)
}

if !isAstGrepBinary(shim) {
t.Fatalf("isAstGrepBinary should accept a working ast-grep.cmd shim: %s", shim)
}
}

func TestScanDirectoryUsesCmdShimBinary(t *testing.T) {
if runtime.GOOS != "windows" {
t.Skip("windows-only: cmd.exe shims")
}

tmpDir := t.TempDir()
scanned := filepath.Join(tmpDir, "project")
if err := os.MkdirAll(scanned, 0755); err != nil {
t.Fatalf("failed to create scan target: %v", err)
}
if err := os.WriteFile(filepath.Join(scanned, "main.ts"), []byte("import x from \"./y\";\n"), 0644); err != nil {
t.Fatalf("failed to create source file: %v", err)
}

shim := filepath.Join(tmpDir, "ast-grep.cmd")
// `exit 0` after printing the JSON array; cmd echo needs delayed expansion
// avoided — the array is static.
script := "@echo []\r\n@exit /b 0\r\n"
if err := os.WriteFile(shim, []byte(script), 0644); err != nil {
t.Fatalf("failed to create fake ast-grep.cmd shim: %v", err)
}

s, err := NewAstGrepScanner()
if err != nil {
t.Fatalf("NewAstGrepScanner: %v", err)
}
t.Cleanup(s.Close)
s.binary = shim

outcome, err := s.ScanDirectory(context.Background(), scanned)
if err != nil {
t.Fatalf("ScanDirectory via cmd shim failed: %v", err)
}
if outcome.Sources[0].Status != analysis.SourceAuthoritative {
t.Fatalf("expected authoritative source status, got %v", outcome.Sources[0].Status)
}
}
Loading