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
54 changes: 41 additions & 13 deletions cmd/doctor.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,8 @@ var (
"/Applications/ChatGPT.app/Contents/Resources/codex",
"/Applications/Codex.app/Contents/Resources/codex",
}
doctorRuntimeVersionProbe = probeDoctorCodexRuntimeVersion
doctorWindowsBundledCodexCLICandidates = discoverWindowsBundledCodexCLICandidates
doctorRuntimeVersionProbe = probeDoctorCodexRuntimeVersion
)

const doctorProbeOutputLimit = 8 * 1024
Expand Down Expand Up @@ -158,30 +159,34 @@ func RunDoctor(args []string, defaultRoot string) int {
}

func reportCodexRuntimeVersions(cliPath string) bool {
if doctorRuntimeGOOS != "darwin" {
var desktopPaths []string
switch doctorRuntimeGOOS {
case "darwin":
desktopPaths = validatedDoctorDesktopCodexCandidates(doctorDesktopCodexCandidates, "darwin")
case "windows":
desktopPaths = validatedDoctorDesktopCodexCandidates(doctorWindowsBundledCodexCLICandidates(), "windows")
default:
return false
}
desktopPaths := make([]string, 0, len(doctorDesktopCodexCandidates))
for _, candidate := range doctorDesktopCodexCandidates {
if _, err := validateIntegrationExecutable(candidate, "darwin"); err == nil {
desktopPaths = append(desktopPaths, candidate)
}
}
if len(desktopPaths) == 0 {
return false
}

cliVersion := ""
var cliErr error
if cliPath != "" {
cliVersion, cliErr = doctorRuntimeVersionProbe(cliPath)
if cliErr != nil {
fmt.Printf("WARN Codex CLI runtime: %s (%v)\n", cliPath, cliErr)
cliVersion, err := doctorRuntimeVersionProbe(cliPath)
if err != nil {
fmt.Printf("WARN Codex CLI runtime: %s (%v)\n", cliPath, err)
} else {
fmt.Printf("OK Codex CLI runtime: %s (%s)\n", cliPath, cliVersion)
}
}
for _, desktopPath := range desktopPaths {
if doctorRuntimeGOOS == "windows" {
appPath := filepath.Join(filepath.Dir(filepath.Dir(desktopPath)), "ChatGPT.exe")
if _, err := validateIntegrationExecutable(appPath, "windows"); err == nil {
fmt.Printf("OK Codex Desktop app: %s\n", appPath)
}
}
desktopVersion, err := doctorRuntimeVersionProbe(desktopPath)
if err != nil {
fmt.Printf("WARN Codex Desktop runtime: %s (%v)\n", desktopPath, err)
Expand All @@ -192,6 +197,29 @@ func reportCodexRuntimeVersions(cliPath string) bool {
return true
}

func validatedDoctorDesktopCodexCandidates(candidates []string, goos string) []string {
desktopPaths := make([]string, 0, len(candidates))
for _, candidate := range candidates {
if _, err := validateIntegrationExecutable(candidate, goos); err == nil {
desktopPaths = append(desktopPaths, candidate)
}
}
return desktopPaths
}

func discoverWindowsBundledCodexCLICandidates() []string {
programFiles := os.Getenv("ProgramFiles")
if programFiles == "" {
return nil
}
pattern := filepath.Join(programFiles, "WindowsApps", "OpenAI.Codex_*", "app", "resources", "codex.exe")
candidates, err := filepath.Glob(pattern)
if err != nil {
return nil
}
return candidates
}

func activeCodexPluginMCPPath() (string, bool) {
out, err := runCodexPluginCommand("", "plugin", "list", "--json")
if err != nil {
Expand Down
162 changes: 147 additions & 15 deletions cmd/setup_review_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -337,25 +337,157 @@ func TestValidateCodexHookTrust(t *testing.T) {
}

func TestRunDoctorReportsUnconfiguredProject(t *testing.T) {
root := t.TempDir()
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)

originalLook := doctorLookPath
doctorLookPath = func(string) (string, error) { return "", os.ErrNotExist }
t.Cleanup(func() { doctorLookPath = originalLook })
originalGOOS := doctorRuntimeGOOS
originalCandidates := doctorDesktopCodexCandidates
originalVersionProbe := doctorRuntimeVersionProbe
originalWindowsCandidates := doctorWindowsBundledCodexCLICandidates
t.Cleanup(func() {
doctorLookPath = originalLook
doctorRuntimeGOOS = originalGOOS
doctorDesktopCodexCandidates = originalCandidates
doctorRuntimeVersionProbe = originalVersionProbe
doctorWindowsBundledCodexCLICandidates = originalWindowsCandidates
})

for _, tt := range []struct {
name string
goos string
cliName string
desktopBundle string
windowsDesktop bool
wantVersionProbes int
want []string
absent []string
}{
{
name: "neither",
goos: "darwin",
want: []string{"MISS project config", "no supported coding agent", "need attention"},
},
{
name: "CLI only",
goos: "darwin",
cliName: "codex",
want: []string{"MISS project config", "OK Codex executable is installed", "need attention"},
absent: []string{"no supported coding agent", "Codex Desktop runtime"},
},
{
name: "ChatGPT Desktop only",
goos: "darwin",
desktopBundle: "ChatGPT.app",
wantVersionProbes: 1,
want: []string{"MISS project config", "OK Codex Desktop runtime", "ChatGPT.app/Contents/Resources/codex", "need attention"},
absent: []string{"no supported coding agent", "Codex CLI runtime"},
},
{
name: "CLI and Codex Desktop",
goos: "darwin",
cliName: "codex",
desktopBundle: "Codex.app",
wantVersionProbes: 2,
want: []string{"MISS project config", "OK Codex executable is installed", "OK Codex CLI runtime", "OK Codex Desktop runtime", "Codex.app/Contents/Resources/codex", "need attention"},
absent: []string{"no supported coding agent"},
},
{
name: "Linux ignores Desktop",
goos: "linux",
desktopBundle: "ChatGPT.app",
want: []string{"MISS project config", "no supported coding agent", "need attention"},
absent: []string{"Codex Desktop runtime"},
},
{
name: "Windows Desktop only",
goos: "windows",
windowsDesktop: true,
wantVersionProbes: 1,
want: []string{"MISS project config", "OK Codex Desktop app", "ChatGPT.exe", "OK Codex Desktop runtime", "OpenAI.Codex_", "resources/codex.exe", "need attention"},
absent: []string{"no supported coding agent", "Codex CLI runtime"},
},
} {
t.Run(tt.name, func(t *testing.T) {
root := t.TempDir()
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
t.Setenv("PATH", t.TempDir())

doctorLookPath = func(name string) (string, error) {
if name == "codex" && tt.cliName != "" {
return filepath.Join(root, tt.cliName), nil
}
return "", os.ErrNotExist
}
doctorRuntimeGOOS = tt.goos
doctorDesktopCodexCandidates = nil
doctorWindowsBundledCodexCLICandidates = func() []string { return nil }
if tt.desktopBundle != "" {
desktop := filepath.Join(t.TempDir(), tt.desktopBundle, "Contents", "Resources", "codex")
if err := os.MkdirAll(filepath.Dir(desktop), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(desktop, []byte("#!/bin/sh\n"), 0o755); err != nil {
t.Fatal(err)
}
doctorDesktopCodexCandidates = []string{desktop}
}
if tt.windowsDesktop {
appRoot := filepath.Join(t.TempDir(), "WindowsApps", "OpenAI.Codex_26.721.4979.0_x64__futurepublisher", "app")
app := filepath.Join(appRoot, "ChatGPT.exe")
desktop := filepath.Join(appRoot, "resources", "codex.exe")
if err := os.MkdirAll(filepath.Dir(desktop), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(app, []byte("desktop"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(desktop, []byte("codex"), 0o644); err != nil {
t.Fatal(err)
}
doctorWindowsBundledCodexCLICandidates = func() []string { return []string{desktop} }
}
var versionProbes []string
doctorRuntimeVersionProbe = func(path string) (string, error) {
versionProbes = append(versionProbes, path)
return "test", nil
}

var code int
out := captureOutput(func() { code = RunDoctor([]string{root}, ".") })
var code int
out := captureOutput(func() { code = RunDoctor([]string{root}, ".") })
if code != 1 {
t.Fatalf("RunDoctor exit code = %d, want 1 for unconfigured project\n%s", code, out)
}
for _, want := range tt.want {
if !strings.Contains(out, want) {
t.Fatalf("expected %q in doctor output:\n%s", want, out)
}
}
for _, absent := range tt.absent {
if strings.Contains(out, absent) {
t.Fatalf("did not expect %q in doctor output:\n%s", absent, out)
}
}
if len(versionProbes) != tt.wantVersionProbes {
t.Fatalf("version probes = %d, want %d", len(versionProbes), tt.wantVersionProbes)
}
})
}
}

if code != 1 {
t.Fatalf("RunDoctor exit code = %d, want 1 for unconfigured project\n%s", code, out)
func TestDiscoverWindowsBundledCodexCLICandidates(t *testing.T) {
programFiles := t.TempDir()
t.Setenv("ProgramFiles", programFiles)
candidate := filepath.Join(programFiles, "WindowsApps", "OpenAI.Codex_26.721.4979.0_x64__futurepublisher", "app", "resources", "codex.exe")
if err := os.MkdirAll(filepath.Dir(candidate), 0o755); err != nil {
t.Fatal(err)
}
for _, want := range []string{"MISS project config", "no supported coding agent", "need attention"} {
if !strings.Contains(out, want) {
t.Fatalf("expected %q in doctor output:\n%s", want, out)
}
if err := os.WriteFile(candidate, []byte("desktop"), 0o644); err != nil {
t.Fatal(err)
}

candidates := discoverWindowsBundledCodexCLICandidates()
if len(candidates) != 1 || candidates[0] != candidate {
t.Fatalf("candidates = %q, want [%q]", candidates, candidate)
}
}

Expand Down
44 changes: 22 additions & 22 deletions scanner/astgrep.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,37 +41,34 @@ type ScanMatch struct {

// AstGrepScanner uses ast-grep with YAML rules for code analysis
type AstGrepScanner struct {
rulesDir string
binary string // "sg" or "ast-grep", whichever is available
rulesDir string
inlineRules string
binary string // "sg" or "ast-grep", whichever is available
}

// NewAstGrepScanner creates a scanner, extracting rules to temp dir
// NewAstGrepScanner creates a scanner using the embedded rules.
func NewAstGrepScanner() (*AstGrepScanner, error) {
// Find ast-grep binary (installed as "sg" via brew, "ast-grep" via cargo/pipx)
binary := findAstGrepBinary()

// Create temp directory for rules
rulesDir, err := os.MkdirTemp("", "codemap-sg-rules-*")
if err != nil {
return nil, err
}

// Extract embedded rules
entries, err := sgRules.ReadDir("sg-rules")
if err != nil {
os.RemoveAll(rulesDir)
return nil, err
}

var rules []string
for _, entry := range entries {
if !strings.HasSuffix(entry.Name(), ".yml") || entry.Name() == "sgconfig.yml" {
continue
}
content, err := sgRules.ReadFile("sg-rules/" + entry.Name())
if err != nil {
continue
}
os.WriteFile(filepath.Join(rulesDir, entry.Name()), content, 0644)
rules = append(rules, string(content))
}

return &AstGrepScanner{rulesDir: rulesDir, binary: binary}, nil
return &AstGrepScanner{inlineRules: strings.Join(rules, "\n---\n"), binary: binary}, nil
}

// extractJSONArray finds and extracts a JSON array from output that may have garbage before it
Expand Down Expand Up @@ -213,18 +210,21 @@ func (s *AstGrepScanner) ScanDirectory(root string) ([]FileAnalysis, error) {
return nil, nil
}

// Combine all rules into one string with --- separators
var rules []string
entries, _ := os.ReadDir(s.rulesDir)
for _, e := range entries {
if strings.HasSuffix(e.Name(), ".yml") && e.Name() != "sgconfig.yml" {
content, err := os.ReadFile(filepath.Join(s.rulesDir, e.Name()))
if err == nil {
rules = append(rules, string(content))
inlineRules := s.inlineRules
if inlineRules == "" && s.rulesDir != "" {
// Preserve support for manually constructed scanners backed by a rule directory.
var rules []string
entries, _ := os.ReadDir(s.rulesDir)
for _, e := range entries {
if strings.HasSuffix(e.Name(), ".yml") && e.Name() != "sgconfig.yml" {
content, err := os.ReadFile(filepath.Join(s.rulesDir, e.Name()))
if err == nil {
rules = append(rules, string(content))
}
}
}
inlineRules = strings.Join(rules, "\n---\n")
}
inlineRules := strings.Join(rules, "\n---\n")

// Build command args, excluding nested git repos that ast-grep would
// treat as separate repo boundaries (ignoring parent .gitignore)
Expand Down
44 changes: 44 additions & 0 deletions scanner/astgrep_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
)
Expand All @@ -21,6 +22,49 @@ func canonicalTestPath(path string) string {
return path
}

func TestAstGrepScannerUsesEmbeddedRulesWithoutWritableTempDir(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("requires shell script execution")
}

tmpDir := t.TempDir()
blockedTemp := filepath.Join(tmpDir, "not-a-directory")
if err := os.WriteFile(blockedTemp, []byte("blocked"), 0644); err != nil {
t.Fatalf("failed to create blocked temp path: %v", err)
}

capturedArgs := filepath.Join(tmpDir, "args.txt")
fakeBinary := filepath.Join(tmpDir, "fake-sg.sh")
script := "#!/bin/sh\nprintf '%s\\n' \"$@\" > \"$CAPTURE_ARGS\"\nprintf '[]\\n'\n"
if err := os.WriteFile(fakeBinary, []byte(script), 0755); err != nil {
t.Fatalf("failed to create fake ast-grep binary: %v", err)
}

t.Setenv("TMPDIR", blockedTemp)
t.Setenv("TMP", blockedTemp)
t.Setenv("TEMP", blockedTemp)
t.Setenv("CAPTURE_ARGS", capturedArgs)

scanner, err := NewAstGrepScanner()
if err != nil {
t.Fatalf("NewAstGrepScanner should not require writable temp storage: %v", err)
}
t.Cleanup(scanner.Close)
scanner.binary = fakeBinary

if _, err := scanner.ScanDirectory(tmpDir); err != nil {
t.Fatalf("ScanDirectory failed: %v", err)
}

args, err := os.ReadFile(capturedArgs)
if err != nil {
t.Fatalf("failed to read captured arguments: %v", err)
}
if !strings.Contains(string(args), "--inline-rules\n") || !strings.Contains(string(args), "id: go-imports\n") {
t.Fatalf("expected embedded Go rules in --inline-rules argument, got: %s", args)
}
}

func TestAstGrepAnalyzer(t *testing.T) {
analyzer := NewAstGrepAnalyzer()
if !analyzer.Available() {
Expand Down
Loading
Loading