Skip to content
Draft
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
77 changes: 77 additions & 0 deletions analysis/contracts.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
package analysis

import (
"slices"
"sort"
)

const SchemaVersion = "codemap.analysis/v1"

type CoverageStatus string

const (
CoverageComplete CoverageStatus = "complete"
CoveragePartial CoverageStatus = "partial"
CoverageUnavailable CoverageStatus = "unavailable"
)

type SourceStatus string

const (
SourceAuthoritative SourceStatus = "authoritative"
SourceMixed SourceStatus = "mixed"
SourceFallback SourceStatus = "fallback"
SourceTimeout SourceStatus = "timeout"
SourceUnavailable SourceStatus = "unavailable"
SourceFailed SourceStatus = "failed"
)

type Source struct {
Name string `json:"name"`
Status SourceStatus `json:"status"`
Detail string `json:"detail,omitempty"`
}

type Issue struct {
Code string `json:"code"`
Severity string `json:"severity,omitempty"`
Path string `json:"path,omitempty"`
Line int `json:"line,omitempty"`
Message string `json:"message"`
Candidates []string `json:"candidates,omitempty"`
}

type Coverage struct {
Status CoverageStatus `json:"status"`
Sources []Source `json:"sources"`
Issues []Issue `json:"issues"`
}

func NormalizeCoverage(coverage Coverage) Coverage {
coverage.Sources = slices.Clone(coverage.Sources)
coverage.Issues = slices.Clone(coverage.Issues)
if coverage.Sources == nil {
coverage.Sources = []Source{}
}
if coverage.Issues == nil {
coverage.Issues = []Issue{}
}
sort.Slice(coverage.Sources, func(i, j int) bool {
left, right := coverage.Sources[i], coverage.Sources[j]
if left.Name != right.Name {
return left.Name < right.Name
}
if left.Status != right.Status {
return left.Status < right.Status
}
return left.Detail < right.Detail
})
for index := range coverage.Issues {
coverage.Issues[index].Candidates = slices.Clone(coverage.Issues[index].Candidates)
if coverage.Issues[index].Candidates == nil {
coverage.Issues[index].Candidates = []string{}
}
sort.Strings(coverage.Issues[index].Candidates)
}
return coverage
}
45 changes: 45 additions & 0 deletions analysis/contracts_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package analysis

import (
"encoding/json"
"reflect"
"testing"
)

func TestNormalizeCoverageProducesDeterministicNonNilCollections(t *testing.T) {
coverage := NormalizeCoverage(Coverage{
Status: CoveragePartial,
Sources: []Source{
{Name: "zeta", Status: SourceFallback},
{Name: "alpha", Status: SourceAuthoritative},
},
Issues: []Issue{{Code: "ambiguous", Message: "choose", Candidates: []string{"z", "a"}}},
})
if got, want := []string{coverage.Sources[0].Name, coverage.Sources[1].Name}, []string{"alpha", "zeta"}; !reflect.DeepEqual(got, want) {
t.Fatalf("source order = %v, want %v", got, want)
}
if got, want := coverage.Issues[0].Candidates, []string{"a", "z"}; !reflect.DeepEqual(got, want) {
t.Fatalf("candidates = %v, want %v", got, want)
}
encoded, err := json.Marshal(NormalizeCoverage(Coverage{}))
if err != nil {
t.Fatal(err)
}
if string(encoded) == "" || string(encoded) == `{"status":"","sources":null,"issues":null}` {
t.Fatalf("coverage must encode non-null collections: %s", encoded)
}
}

func TestNormalizeCoveragePreservesIssueOrderAndSortsRepeatedFields(t *testing.T) {
input := Coverage{
Sources: []Source{{Name: "same", Status: SourceFallback}, {Name: "same", Status: SourceAuthoritative}},
Issues: []Issue{{Code: "z", Candidates: []string{"b", "a"}}, {Code: "a"}},
}
got := NormalizeCoverage(input)
if got.Sources[0].Status != SourceAuthoritative {
t.Fatalf("source tie order = %#v", got.Sources)
}
if got.Issues[0].Code != "z" || !reflect.DeepEqual(got.Issues[0].Candidates, []string{"a", "b"}) || got.Issues[1].Code != "a" {
t.Fatalf("issue order = %#v", got.Issues)
}
}
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ go 1.24.0
require (
github.com/charmbracelet/bubbletea v1.3.10
github.com/fsnotify/fsnotify v1.9.0
github.com/google/jsonschema-go v0.3.0
github.com/modelcontextprotocol/go-sdk v1.1.0
github.com/pelletier/go-toml/v2 v2.2.4
github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06
Expand All @@ -20,7 +21,6 @@ require (
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
github.com/charmbracelet/x/term v0.2.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/google/jsonschema-go v0.3.0 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
Expand Down
36 changes: 20 additions & 16 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -455,10 +455,11 @@ type stdinManifest struct {
func runDepsMode(absRoot, root string, jsonMode bool, diffRef string, changedFiles map[string]bool, stdinMode bool, filters scanner.Filters) {
var analyses []FileAnalysis
var externalDeps map[string][]string
var inventory []scanner.FileInfo
var err error

if stdinMode {
analyses, externalDeps, err = runDepsFromStdin(filters)
analyses, externalDeps, inventory, err = runDepsFromStdin(filters)
if err != nil {
fmt.Fprintf(os.Stderr, "Error reading stdin manifest: %v\n", err)
os.Exit(1)
Expand All @@ -478,20 +479,19 @@ func runDepsMode(absRoot, root string, jsonMode bool, diffRef string, changedFil
os.Exit(1)
}
externalDeps = scanner.ReadExternalDeps(absRoot)
inventory, err = scanner.ScanFiles(absRoot, scanner.NewGitIgnoreCache(absRoot), filters.Only, filters.Exclude)
if err != nil {
fmt.Fprintf(os.Stderr, "Error scanning configured files: %v\n", err)
os.Exit(1)
}
}

// Filter to changed files if --diff specified
if changedFiles != nil {
analyses = scanner.FilterAnalysisToChanged(analyses, changedFiles)
}

depsProject := scanner.DepsProject{
Root: absRoot,
Mode: "deps",
Files: analyses,
ExternalDeps: externalDeps,
DiffRef: diffRef,
}
depsProject := scanner.NewDepsProject(absRoot, analyses, externalDeps, diffRef, inventory)

// Render or output JSON
if jsonMode {
Expand All @@ -509,43 +509,47 @@ func scanForDepsWithHint(root string, filters scanner.Filters) ([]FileAnalysis,
// runDepsFromStdin reads a JSON manifest from stdin, writes files to a temp
// directory, runs ast-grep on it, and returns the results with paths matching
// the original manifest.
func runDepsFromStdin(filters scanner.Filters) ([]FileAnalysis, map[string][]string, error) {
func runDepsFromStdin(filters scanner.Filters) ([]FileAnalysis, map[string][]string, []scanner.FileInfo, error) {
var manifest stdinManifest
if err := json.NewDecoder(os.Stdin).Decode(&manifest); err != nil {
return nil, nil, fmt.Errorf("invalid JSON: %w", err)
return nil, nil, nil, fmt.Errorf("invalid JSON: %w", err)
}

if len(manifest.Files) == 0 {
return nil, nil, nil
return nil, nil, nil, nil
}

// Create temp directory and write manifest files
tempDir, err := os.MkdirTemp("", "codemap-stdin-*")
if err != nil {
return nil, nil, fmt.Errorf("failed to create temp dir: %w", err)
return nil, nil, nil, fmt.Errorf("failed to create temp dir: %w", err)
}
defer os.RemoveAll(tempDir)

for _, f := range manifest.Files {
dest := filepath.Join(tempDir, f.Path)
if err := os.MkdirAll(filepath.Dir(dest), 0755); err != nil {
return nil, nil, fmt.Errorf("mkdir %s: %w", filepath.Dir(dest), err)
return nil, nil, nil, fmt.Errorf("mkdir %s: %w", filepath.Dir(dest), err)
}
if err := os.WriteFile(dest, []byte(f.Content), 0644); err != nil {
return nil, nil, fmt.Errorf("write %s: %w", f.Path, err)
return nil, nil, nil, fmt.Errorf("write %s: %w", f.Path, err)
}
}

// Run ast-grep on temp directory
analyses, err := scanner.ScanForDepsWithFilters(tempDir, filters)
if err != nil {
return nil, nil, err
return nil, nil, nil, err
}

// Read external deps from temp directory (manifest may include go.mod etc.)
externalDeps := scanner.ReadExternalDeps(tempDir)
inventory, err := scanner.ScanFiles(tempDir, scanner.NewGitIgnoreCache(tempDir), filters.Only, filters.Exclude)
if err != nil {
return nil, nil, nil, err
}

return analyses, externalDeps, nil
return analyses, externalDeps, inventory, nil
}

// FileAnalysis is a type alias for use in main package.
Expand Down
6 changes: 5 additions & 1 deletion main_more_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"testing"
"time"

"codemap/analysis"
"codemap/config"
"codemap/handoff"
"codemap/scanner"
Expand Down Expand Up @@ -352,6 +353,9 @@ func TestRunDepsModeJSONAndMainDispatchesDepsAndImporters(t *testing.T) {

root := t.TempDir()
writeImportersFixture(t, root)
if err := os.WriteFile(filepath.Join(root, "lib.rs"), []byte("pub struct Value;\n"), 0o644); err != nil {
t.Fatal(err)
}

stdout, _ := captureMainStreams(t, func() {
runDepsMode(root, root, true, "main", map[string]bool{"a/a.go": true}, false, scanner.Filters{})
Expand All @@ -375,7 +379,7 @@ func TestRunDepsModeJSONAndMainDispatchesDepsAndImporters(t *testing.T) {
if err := json.Unmarshal([]byte(stdout), &depsProject); err != nil {
t.Fatalf("expected main deps JSON output, got error %v with body:\n%s", err, stdout)
}
if depsProject.Mode != "deps" || len(depsProject.Files) == 0 {
if depsProject.Mode != "deps" || len(depsProject.Files) == 0 || depsProject.Coverage.Status != analysis.CoveragePartial {
t.Fatalf("expected deps project output, got %+v", depsProject)
}

Expand Down
68 changes: 68 additions & 0 deletions mcp/analysis_output_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package codemapmcp

import (
"context"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"time"

"codemap/analysis"
"codemap/scanner"

"github.com/modelcontextprotocol/go-sdk/mcp"
)

func TestGetDependenciesReturnsTextAndStructuredContent(t *testing.T) {
if !scanner.NewAstGrepAnalyzer().Available() {
t.Skip("ast-grep not available")
}
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, "main.go"), []byte("package main\n\nfunc main() {}\n"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "lib.rs"), []byte("pub struct Value;\n"), 0o644); err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
serverTransport, clientTransport := mcp.NewInMemoryTransports()
if _, err := NewServer(RuntimeOptions{}).Connect(ctx, serverTransport, nil); err != nil {
t.Fatal(err)
}
client := mcp.NewClient(&mcp.Implementation{Name: "structured-output-test", Version: "1"}, nil)
session, err := client.Connect(ctx, clientTransport, nil)
if err != nil {
t.Fatal(err)
}
defer session.Close()
tools, err := session.ListTools(ctx, nil)
if err != nil {
t.Fatal(err)
}
for _, tool := range tools.Tools {
if tool.Name == "get_dependencies" && tool.OutputSchema == nil {
t.Fatal("get_dependencies does not advertise an output schema")
}
}
result, err := session.CallTool(ctx, &mcp.CallToolParams{Name: "get_dependencies", Arguments: map[string]any{"path": root}})
if err != nil {
t.Fatal(err)
}
if result.IsError || !strings.Contains(resultText(t, result), "Dependency Flow") || result.StructuredContent == nil {
t.Fatalf("structured dependency result = %#v", result)
}
encoded, err := json.Marshal(result.StructuredContent)
if err != nil {
t.Fatal(err)
}
var output scanner.DepsProject
if err := json.Unmarshal(encoded, &output); err != nil {
t.Fatal(err)
}
if output.SchemaVersion != analysis.SchemaVersion || output.Coverage.Status != analysis.CoveragePartial {
t.Fatalf("structured output = %#v", output)
}
}
32 changes: 21 additions & 11 deletions mcp/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import (
"codemap/skills"
"codemap/watch"

"github.com/google/jsonschema-go/jsonschema"
"github.com/modelcontextprotocol/go-sdk/mcp"
)

Expand Down Expand Up @@ -170,10 +171,12 @@ func NewServer(options RuntimeOptions) *mcp.Server {
}, handleGetStructure)

// Tool: get_dependencies - Get dependency graph
mcp.AddTool(server, &mcp.Tool{
Name: "get_dependencies",
Description: "Get the dependency flow of a project. Shows external dependencies by language, internal import chains between files, hub files (most-imported), and function counts. Use this to understand how code connects and which files are most critical.",
}, handleGetDependencies)
dependenciesTool := &mcp.Tool{
Name: "get_dependencies",
Description: "Get the dependency flow of a project. Shows external dependencies by language, internal import chains between files, hub files (most-imported), and function counts. Use this to understand how code connects and which files are most critical.",
OutputSchema: mustSchemaFor[scanner.DepsProject](),
}
mcp.AddTool(server, dependenciesTool, handleGetDependencies)

// Tool: get_diff - Get changed files with impact analysis
mcp.AddTool(server, &mcp.Tool{
Expand Down Expand Up @@ -289,6 +292,14 @@ func errorResult(text string) *mcp.CallToolResult {
}
}

func mustSchemaFor[T any]() *jsonschema.Schema {
schema, err := jsonschema.For[T](nil)
if err != nil {
panic(fmt.Sprintf("infer MCP schema: %v", err))
}
return schema
}

func handleGetStructure(ctx context.Context, req *mcp.CallToolRequest, input StructureInput) (*mcp.CallToolResult, any, error) {
absRoot, err := filepath.Abs(input.Path)
if err != nil {
Expand Down Expand Up @@ -373,19 +384,18 @@ func handleGetDependencies(ctx context.Context, req *mcp.CallToolRequest, input
if err != nil {
return errorResult("Scan error: " + err.Error()), nil, nil
}

depsProject := scanner.DepsProject{
Root: absRoot,
Mode: "deps",
Files: analyses,
ExternalDeps: scanner.ReadExternalDeps(absRoot),
inventory, err := scanner.ScanConfiguredFiles(absRoot, scanner.NewGitIgnoreCache(absRoot))
if err != nil {
return errorResult("Scan error: " + err.Error()), nil, nil
}

depsProject := scanner.NewDepsProject(absRoot, analyses, scanner.ReadExternalDeps(absRoot), "", inventory)

var buf bytes.Buffer
render.Depgraph(&buf, depsProject)
output := buf.String()

return textResult(output), nil, nil
return textResult(output), depsProject, nil
}

func handleGetDiff(ctx context.Context, req *mcp.CallToolRequest, input DiffInput) (*mcp.CallToolResult, any, error) {
Expand Down
Loading
Loading