a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
267 lines
8.2 KiB
Go
267 lines
8.2 KiB
Go
package mcp
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/mark3labs/mcp-go/mcp"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"github.com/zzet/gortex/internal/graph"
|
|
)
|
|
|
|
func newGenerateSkillTestServer(t *testing.T) *Server {
|
|
t.Helper()
|
|
return &Server{
|
|
graph: graph.New(),
|
|
session: newSessionState(),
|
|
tokenStats: &tokenStats{},
|
|
symHistory: &symbolHistory{entries: make(map[string][]SymbolModification)},
|
|
sessions: newSessionMap(),
|
|
toolScopes: newScopeRegistry(),
|
|
}
|
|
}
|
|
|
|
// seedSkillSource builds a small source tree the tool will bundle.
|
|
// Returns the absolute path to the directory.
|
|
func seedSkillSource(t *testing.T) string {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
require.NoError(t, os.WriteFile(filepath.Join(dir, "main.go"),
|
|
[]byte("package main\n\nfunc Run() int {\n\tfor i := 0; i < 5; i++ {\n\t\t_ = i\n\t}\n\treturn 42\n}\n"), 0o644))
|
|
require.NoError(t, os.WriteFile(filepath.Join(dir, "helpers.go"),
|
|
[]byte("package main\n\nfunc Helper() string { return \"hi\" }\n"), 0o644))
|
|
require.NoError(t, os.WriteFile(filepath.Join(dir, "README.md"),
|
|
[]byte("# Demo project\n"), 0o644))
|
|
require.NoError(t, os.MkdirAll(filepath.Join(dir, "node_modules"), 0o755))
|
|
require.NoError(t, os.WriteFile(filepath.Join(dir, "node_modules", "junk.js"),
|
|
[]byte("// noise\n"), 0o644))
|
|
return dir
|
|
}
|
|
|
|
func callGenerateSkill(t *testing.T, s *Server, args map[string]any) map[string]any {
|
|
t.Helper()
|
|
req := mcp.CallToolRequest{}
|
|
req.Params.Arguments = args
|
|
res, err := s.handleGenerateSkill(context.Background(), req)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, res)
|
|
if res.IsError {
|
|
return map[string]any{"is_error": true, "content": res.Content}
|
|
}
|
|
tc, ok := res.Content[0].(mcp.TextContent)
|
|
require.True(t, ok)
|
|
var m map[string]any
|
|
require.NoError(t, json.Unmarshal([]byte(tc.Text), &m))
|
|
return m
|
|
}
|
|
|
|
func TestGenerateSkill_HappyPath(t *testing.T) {
|
|
s := newGenerateSkillTestServer(t)
|
|
srcDir := seedSkillSource(t)
|
|
outDir := t.TempDir()
|
|
|
|
out := callGenerateSkill(t, s, map[string]any{
|
|
"directory": srcDir,
|
|
"output_dir": outDir,
|
|
})
|
|
|
|
skillPath := out["skill_path"].(string)
|
|
require.FileExists(t, skillPath)
|
|
body, _ := os.ReadFile(skillPath)
|
|
assert.Contains(t, string(body), "name:", "frontmatter present")
|
|
assert.Contains(t, string(body), "description:", "frontmatter present")
|
|
assert.Contains(t, string(body), "## References", "reference section present")
|
|
|
|
// References tree exists with the source files but not node_modules.
|
|
assert.FileExists(t, filepath.Join(outDir, "references", "main.go"))
|
|
assert.FileExists(t, filepath.Join(outDir, "references", "helpers.go"))
|
|
assert.NoFileExists(t, filepath.Join(outDir, "references", "node_modules", "junk.js"))
|
|
}
|
|
|
|
func TestGenerateSkill_SluggifiesDefaultName(t *testing.T) {
|
|
s := newGenerateSkillTestServer(t)
|
|
// Directory with spaces and special chars.
|
|
parent := t.TempDir()
|
|
srcDir := filepath.Join(parent, "My Demo Project!")
|
|
require.NoError(t, os.MkdirAll(srcDir, 0o755))
|
|
require.NoError(t, os.WriteFile(filepath.Join(srcDir, "x.go"), []byte("package x\n"), 0o644))
|
|
|
|
out := callGenerateSkill(t, s, map[string]any{
|
|
"directory": srcDir,
|
|
"output_dir": filepath.Join(t.TempDir(), "out"),
|
|
})
|
|
assert.Equal(t, "my-demo-project", out["skill_name"])
|
|
}
|
|
|
|
func TestGenerateSkill_IncludeFilter(t *testing.T) {
|
|
s := newGenerateSkillTestServer(t)
|
|
srcDir := seedSkillSource(t)
|
|
outDir := t.TempDir()
|
|
|
|
out := callGenerateSkill(t, s, map[string]any{
|
|
"directory": srcDir,
|
|
"output_dir": outDir,
|
|
"include_patterns": "*.go",
|
|
})
|
|
|
|
refs, _ := out["references"].([]any)
|
|
assert.NotEmpty(t, refs)
|
|
for _, r := range refs {
|
|
m := r.(map[string]any)
|
|
assert.True(t, strings.HasSuffix(m["rel_path"].(string), ".go"))
|
|
}
|
|
}
|
|
|
|
func TestGenerateSkill_IgnoreFilter(t *testing.T) {
|
|
s := newGenerateSkillTestServer(t)
|
|
srcDir := seedSkillSource(t)
|
|
outDir := t.TempDir()
|
|
|
|
out := callGenerateSkill(t, s, map[string]any{
|
|
"directory": srcDir,
|
|
"output_dir": outDir,
|
|
"ignore_patterns": "helpers.go",
|
|
})
|
|
|
|
refs, _ := out["references"].([]any)
|
|
for _, r := range refs {
|
|
m := r.(map[string]any)
|
|
assert.NotEqual(t, "helpers.go", m["rel_path"])
|
|
}
|
|
}
|
|
|
|
func TestGenerateSkill_DryRunWritesNothing(t *testing.T) {
|
|
s := newGenerateSkillTestServer(t)
|
|
srcDir := seedSkillSource(t)
|
|
outDir := filepath.Join(t.TempDir(), "out")
|
|
|
|
out := callGenerateSkill(t, s, map[string]any{
|
|
"directory": srcDir,
|
|
"output_dir": outDir,
|
|
"dry_run": true,
|
|
})
|
|
|
|
assert.Equal(t, true, out["dry_run"])
|
|
_, statErr := os.Stat(outDir)
|
|
assert.True(t, os.IsNotExist(statErr), "dry_run must not create output dir")
|
|
|
|
refs, _ := out["references"].([]any)
|
|
assert.NotEmpty(t, refs, "dry_run still reports the candidate references")
|
|
}
|
|
|
|
func TestGenerateSkill_CompressBraceBodies(t *testing.T) {
|
|
s := newGenerateSkillTestServer(t)
|
|
srcDir := seedSkillSource(t)
|
|
outDir := t.TempDir()
|
|
|
|
out := callGenerateSkill(t, s, map[string]any{
|
|
"directory": srcDir,
|
|
"output_dir": outDir,
|
|
"compress": true,
|
|
})
|
|
|
|
body, _ := os.ReadFile(filepath.Join(outDir, "references", "main.go"))
|
|
assert.Contains(t, string(body), "lines elided", "Run's body should be elided")
|
|
assert.Contains(t, string(body), "func Run()", "signature kept")
|
|
|
|
// At least one reference should report compressed=true.
|
|
refs, _ := out["references"].([]any)
|
|
hasCompressed := false
|
|
for _, r := range refs {
|
|
if r.(map[string]any)["compressed"] == true {
|
|
hasCompressed = true
|
|
break
|
|
}
|
|
}
|
|
assert.True(t, hasCompressed)
|
|
}
|
|
|
|
func TestGenerateSkill_MaxReferencesCap(t *testing.T) {
|
|
s := newGenerateSkillTestServer(t)
|
|
srcDir := t.TempDir()
|
|
for i := range 10 {
|
|
require.NoError(t, os.WriteFile(filepath.Join(srcDir, "f"+string(rune('A'+i))+".go"),
|
|
[]byte("package x\n"), 0o644))
|
|
}
|
|
|
|
out := callGenerateSkill(t, s, map[string]any{
|
|
"directory": srcDir,
|
|
"output_dir": t.TempDir(),
|
|
"max_references": 3,
|
|
})
|
|
refs, _ := out["references"].([]any)
|
|
assert.LessOrEqual(t, len(refs), 3)
|
|
assert.GreaterOrEqual(t, out["skipped"].(float64), 7.0)
|
|
}
|
|
|
|
func TestGenerateSkill_DefaultsToClaudeSkillsPath(t *testing.T) {
|
|
s := newGenerateSkillTestServer(t)
|
|
// Init a git repo so repoRootContaining resolves the root.
|
|
dir := t.TempDir()
|
|
require.NoError(t, os.MkdirAll(filepath.Join(dir, ".git"), 0o755))
|
|
srcDir := filepath.Join(dir, "src")
|
|
require.NoError(t, os.MkdirAll(srcDir, 0o755))
|
|
require.NoError(t, os.WriteFile(filepath.Join(srcDir, "a.go"), []byte("package x\n"), 0o644))
|
|
|
|
out := callGenerateSkill(t, s, map[string]any{
|
|
"directory": srcDir,
|
|
"skill_name": "demo",
|
|
})
|
|
|
|
expectedSkillDir := filepath.Join(dir, ".claude", "skills", "demo")
|
|
assert.Equal(t, expectedSkillDir, out["output_dir"])
|
|
assert.FileExists(t, filepath.Join(expectedSkillDir, "SKILL.md"))
|
|
}
|
|
|
|
func TestGenerateSkill_RejectsMissingDirectory(t *testing.T) {
|
|
s := newGenerateSkillTestServer(t)
|
|
out := callGenerateSkill(t, s, map[string]any{})
|
|
assert.True(t, out["is_error"] == true)
|
|
}
|
|
|
|
func TestGenerateSkill_RejectsNonDirectory(t *testing.T) {
|
|
s := newGenerateSkillTestServer(t)
|
|
dir := t.TempDir()
|
|
file := filepath.Join(dir, "not_a_dir.txt")
|
|
require.NoError(t, os.WriteFile(file, []byte("x"), 0o644))
|
|
out := callGenerateSkill(t, s, map[string]any{"directory": file})
|
|
assert.True(t, out["is_error"] == true)
|
|
}
|
|
|
|
// Unit-level checks for the helpers.
|
|
|
|
func TestSluggify(t *testing.T) {
|
|
cases := map[string]string{
|
|
"my-skill": "my-skill",
|
|
"My Skill": "my-skill",
|
|
"my!skill!": "my-skill",
|
|
" spaces ": "spaces",
|
|
"My_skill.123": "my-skill-123",
|
|
}
|
|
for in, want := range cases {
|
|
assert.Equal(t, want, sluggify(in), "sluggify(%q)", in)
|
|
}
|
|
}
|
|
|
|
func TestElideBraceBodies_KeepsSignatures(t *testing.T) {
|
|
src := "func A() {\n\tfor i := 0; i < 10; i++ {\n\t\tdoWork(i)\n\t}\n}\n\nfunc B() int { return 1 }\n"
|
|
out := elideBraceBodies(src)
|
|
assert.Contains(t, out, "func A()")
|
|
assert.Contains(t, out, "lines elided")
|
|
// One-line body should stay verbatim.
|
|
assert.Contains(t, out, "{ return 1 }")
|
|
}
|
|
|
|
func TestMatchPathPattern(t *testing.T) {
|
|
assert.True(t, matchPathPattern("*.go", "foo.go"))
|
|
assert.True(t, matchPathPattern("*.go", "sub/foo.go"))
|
|
assert.True(t, matchPathPattern("vendor", "vendor/x.go"))
|
|
assert.True(t, matchPathPattern("vendor/*", "vendor/x.go"))
|
|
assert.False(t, matchPathPattern("*.ts", "foo.go"))
|
|
}
|