diff --git a/cmd/doctor.go b/cmd/doctor.go index 23d3c9f..df48be6 100644 --- a/cmd/doctor.go +++ b/cmd/doctor.go @@ -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 @@ -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) @@ -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 { diff --git a/cmd/setup_review_test.go b/cmd/setup_review_test.go index d21e2a8..d326ef2 100644 --- a/cmd/setup_review_test.go +++ b/cmd/setup_review_test.go @@ -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) } } diff --git a/scanner/astgrep.go b/scanner/astgrep.go index 3deb803..d0d5dac 100644 --- a/scanner/astgrep.go +++ b/scanner/astgrep.go @@ -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 @@ -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) diff --git a/scanner/astgrep_test.go b/scanner/astgrep_test.go index a74d07c..0fc473e 100644 --- a/scanner/astgrep_test.go +++ b/scanner/astgrep_test.go @@ -5,6 +5,7 @@ import ( "os" "path/filepath" "runtime" + "strings" "testing" "time" ) @@ -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() { diff --git a/scanner/integration_more_test.go b/scanner/integration_more_test.go index 6558632..457d1d0 100644 --- a/scanner/integration_more_test.go +++ b/scanner/integration_more_test.go @@ -306,7 +306,7 @@ func TestScanForDepsPropagatesScanError(t *testing.T) { } } -func TestScanForDepsPropagatesSetupError(t *testing.T) { +func TestScanForDepsDoesNotRequireWritableTempDir(t *testing.T) { invalidTemp := filepath.Join(t.TempDir(), "not-a-directory") if err := os.WriteFile(invalidTemp, []byte("occupied"), 0o644); err != nil { t.Fatal(err) @@ -315,7 +315,7 @@ func TestScanForDepsPropagatesSetupError(t *testing.T) { t.Setenv("TMP", invalidTemp) t.Setenv("TEMP", invalidTemp) - if _, err := ScanForDeps(t.TempDir()); err == nil { - t.Fatal("expected invalid temporary directory to propagate a scanner setup error") + if _, err := ScanForDeps(t.TempDir()); err != nil { + t.Fatalf("ScanForDeps should not require writable temporary storage: %v", err) } }