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
11 changes: 11 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,20 @@ jobs:
test:
name: Test
runs-on: ${{ matrix.os }}
continue-on-error: ${{ matrix.experimental == true }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest]
go-version: ['1.24', '1.25', '1.26']
include:
# One Windows test job: before #139 Windows only built, never ran
# tests, so a whole platform's correctness was asserted by a build.
# Informational until #214 (124 pre-existing Windows failures,
# grouped by cause there) is empty; it still reports on every PR.
- os: windows-latest
go-version: '1.26'
experimental: true

steps:
- uses: actions/checkout@v4
Expand All @@ -30,6 +39,8 @@ jobs:
pip install ast-grep-cli
elif [ "$RUNNER_OS" = "macOS" ]; then
brew install ast-grep
elif [ "$RUNNER_OS" = "Windows" ]; then
pip install ast-grep-cli
fi
shell: bash

Expand Down
33 changes: 22 additions & 11 deletions mcp/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -399,9 +399,27 @@ func mustSchemaFor[T any]() *jsonschema.Schema {
return schema
}

// expandHome resolves a leading "~" against the user's home directory.
// os.UserHomeDir reads USERPROFILE on Windows, where HOME is normally unset;
// joining os.Getenv("HOME") there produced a relative path that filepath.Abs
// resolved against the server's cwd, so "~/proj" could silently analyse the
// wrong project. When no home is known the caller gets an error naming the
// path instead of a guess.
func expandHome(path string) (string, error) {
if path != "~" && !strings.HasPrefix(path, "~/") && !strings.HasPrefix(path, `~\`) {
return path, nil
}
home, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("cannot expand %q: %w", path, err)
}
return filepath.Join(home, strings.TrimLeft(path[1:], `/\`)), nil
}

func validateProjectPath(path string) (string, *mcp.CallToolResult) {
if strings.HasPrefix(path, "~/") {
path = filepath.Join(os.Getenv("HOME"), path[2:])
path, err := expandHome(path)
if err != nil {
return "", errorResult("Invalid path: " + err.Error())
}
absPath, err := filepath.Abs(path)
if err != nil {
Expand Down Expand Up @@ -642,7 +660,7 @@ type EmptyInput struct{}

func handleStatus(ctx context.Context, req *mcp.CallToolRequest, input EmptyInput) (*mcp.CallToolResult, any, error) {
cwd, _ := os.Getwd()
home := os.Getenv("HOME")
home, _ := os.UserHomeDir()

// Check active watchers
watchersMu.RLock()
Expand Down Expand Up @@ -693,14 +711,7 @@ func handleListProjects(ctx context.Context, req *mcp.CallToolRequest, input Lis
if cancelled := cancellationResult(ctx, "Project discovery"); cancelled != nil {
return cancelled, nil, nil
}
// Expand ~ to home directory
path := input.Path
if strings.HasPrefix(path, "~/") {
home := os.Getenv("HOME")
path = filepath.Join(home, path[2:])
}

absPath, invalid := validateProjectPath(path)
absPath, invalid := validateProjectPath(input.Path)
if invalid != nil {
return invalid, nil, nil
}
Expand Down
34 changes: 33 additions & 1 deletion mcp/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -579,9 +579,41 @@ func resultText(t *testing.T, res *mcp.CallToolResult) string {
return text.Text
}

// setHome points os.UserHomeDir at dir on every OS: HOME on Unix, USERPROFILE
// on Windows. Tests that set only HOME passed on Windows by supplying the
// variable Windows would not have, which is how #139 stayed invisible.
func setHome(t *testing.T, dir string) {
t.Helper()
t.Setenv("HOME", dir)
t.Setenv("USERPROFILE", dir)
}

func TestExpandHome(t *testing.T) {
home := t.TempDir()
setHome(t, home)
for _, in := range []string{"~/x", `~\x`} {
got, err := expandHome(in)
if err != nil || got != filepath.Join(home, "x") {
t.Fatalf("expandHome(%q) = %q, %v; want %q", in, got, err, filepath.Join(home, "x"))
}
}
if got, err := expandHome("~"); err != nil || got != home {
t.Fatalf("expandHome(~) = %q, %v; want %q", got, err, home)
}
if got, err := expandHome("plain/x"); err != nil || got != "plain/x" {
t.Fatalf("expandHome(plain/x) = %q, %v; want unchanged", got, err)
}
// No home at all: an error naming the path, never a cwd-relative guess.
t.Setenv("HOME", "")
t.Setenv("USERPROFILE", "")
if got, err := expandHome("~/x"); err == nil || !strings.Contains(err.Error(), "~/x") {
t.Fatalf("expandHome without home = %q, %v; want error naming the path", got, err)
}
}

func TestValidateProjectPathExpandsHomeAndRejectsInvalidRoots(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
setHome(t, home)
proj := filepath.Join(home, "proj")
if err := os.MkdirAll(filepath.Join(proj, ".git"), 0o755); err != nil {
t.Fatal(err)
Expand Down
4 changes: 2 additions & 2 deletions mcp/surface_hygiene_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -262,7 +262,7 @@ func TestFileContextUsesNativeKeysAndKeepsProjectRootsRequestLocal(t *testing.T)

func TestFindFileResolvesHomeAndKeepsConfigRootsRequestLocal(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
setHome(t, home)
alpha := filepath.Join(home, "alpha")
beta := filepath.Join(home, "beta")
writeFindProject(t, alpha, `{"only":["go"]}`)
Expand Down Expand Up @@ -471,7 +471,7 @@ func TestValidateProjectPathRejectsInaccessiblePath(t *testing.T) {
t.Fatalf("unexpected rejection: %q", resultText(t, result))
}
home := t.TempDir()
t.Setenv("HOME", home)
setHome(t, home)
if err := os.MkdirAll(filepath.Join(home, "proj"), 0o755); err != nil {
t.Fatal(err)
}
Expand Down
7 changes: 3 additions & 4 deletions mcp/topology.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,8 @@ package codemapmcp
import (
"context"
"encoding/json"
"os"
"path/filepath"
"sort"
"strings"

"codemap/internal/projectpath"
"codemap/limits"
Expand Down Expand Up @@ -145,8 +143,9 @@ func handleGetModuleContext(ctx context.Context, req *mcp.CallToolRequest, input
}

func topologyProjectRoot(path string) (string, error) {
if strings.HasPrefix(path, "~/") {
path = filepath.Join(os.Getenv("HOME"), path[2:])
path, err := expandHome(path)
if err != nil {
return "", err
}
selection, err := projectpath.Select(path)
if err != nil {
Expand Down
Loading