Skip to content
Open
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
72 changes: 67 additions & 5 deletions internal/uninstall/uninstall.go
Original file line number Diff line number Diff line change
Expand Up @@ -314,15 +314,15 @@ func cleanWorkspaceData(home string) {
return
}

var registry map[string]interface{}
if err := json.Unmarshal(data, &registry); err != nil {
warnMsg("Could not parse registry: " + err.Error())
roots := extractWorkspaceRoots(data)
if len(roots) == 0 {
warnMsg("Could not extract workspace paths from registry, scanning common directories...")
scanAndCleanRagcodeDirs(home)
return
}

cleaned := 0
for wsPath := range registry {
for _, wsPath := range roots {
ragDir := filepath.Join(wsPath, ".ragcode")
if _, err := os.Stat(ragDir); err == nil {
if err := os.RemoveAll(ragDir); err != nil {
Expand All @@ -335,10 +335,72 @@ func cleanWorkspaceData(home string) {
}

if cleaned == 0 {
logMsg("No per-workspace .ragcode/ directories found")
logMsg("No per-workspace .ragcode/ directories found in registry entries")
}
}

// extractWorkspaceRoots tries to parse the registry in all known formats
// and returns all workspace root paths found.
func extractWorkspaceRoots(data []byte) []string {
// V2 format: {"version":"v2", "entries":[{"root":"/path",...}], ...}
var v2Store struct {
Version string `json:"version"`
Entries []struct {
Root string `json:"root"`
} `json:"entries"`
}
if err := json.Unmarshal(data, &v2Store); err == nil && v2Store.Version != "" && len(v2Store.Entries) > 0 {
Copy link

Copilot AI Mar 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

V2 registry detection accepts any non-empty version value (v2Store.Version != ""). If a future registry format includes a different version string but still has an entries array, this will be treated as V2 and could cause unintended deletions. Consider checking for the expected value (e.g., == "v2") before trusting the parsed roots.

Suggested change
if err := json.Unmarshal(data, &v2Store); err == nil && v2Store.Version != "" && len(v2Store.Entries) > 0 {
if err := json.Unmarshal(data, &v2Store); err == nil && v2Store.Version == "v2" && len(v2Store.Entries) > 0 {

Copilot uses AI. Check for mistakes.
roots := make([]string, 0, len(v2Store.Entries))
for _, e := range v2Store.Entries {
if e.Root != "" {
roots = append(roots, e.Root)
}
}
if len(roots) > 0 {
logMsg(fmt.Sprintf("Found %d workspace(s) in V2 registry", len(roots)))
return roots
}
}

// V1 format: [{"root":"/path",...},...]
var v1Entries []struct {
Root string `json:"root"`
}
if err := json.Unmarshal(data, &v1Entries); err == nil && len(v1Entries) > 0 {
roots := make([]string, 0, len(v1Entries))
for _, e := range v1Entries {
if e.Root != "" {
roots = append(roots, e.Root)
}
}
if len(roots) > 0 {
logMsg(fmt.Sprintf("Found %d workspace(s) in V1 registry", len(roots)))
return roots
}
}

// Legacy flat map format: {"/path/to/ws": {...}, ...}
var flatMap map[string]interface{}
if err := json.Unmarshal(data, &flatMap); err == nil {
roots := make([]string, 0, len(flatMap))
for key := range flatMap {
// Skip known non-path keys from V2 format
if key == "version" || key == "entries" || key == "candidates" {
continue
}
if key != "" {
roots = append(roots, key)
}
}
if len(roots) > 0 {
logMsg(fmt.Sprintf("Found %d workspace(s) in legacy registry", len(roots)))
return roots
}
}

return nil
}

func scanAndCleanRagcodeDirs(home string) {
searchRoots := []string{
filepath.Join(home, "Projects"),
Expand Down
162 changes: 162 additions & 0 deletions internal/uninstall/uninstall_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
package uninstall

import (
"encoding/json"
"os"
"path/filepath"
"sort"
"testing"
)

func TestExtractWorkspaceRoots_V2(t *testing.T) {
data := []byte(`{
"version": "v2",
"entries": [
{"root": "/home/user/project-a", "id": "abc123"},
{"root": "/home/user/project-b", "id": "def456"},
{"root": "/opt/workspace/app", "id": "ghi789"}
],
"candidates": []
}`)

roots := extractWorkspaceRoots(data)
if len(roots) != 3 {
t.Fatalf("expected 3 roots, got %d: %v", len(roots), roots)
}

sort.Strings(roots)
expected := []string{"/home/user/project-a", "/home/user/project-b", "/opt/workspace/app"}
sort.Strings(expected)

for i, r := range roots {
if r != expected[i] {
t.Errorf("root[%d] = %q, want %q", i, r, expected[i])
}
}
}

func TestExtractWorkspaceRoots_V1(t *testing.T) {
data := []byte(`[
{"root": "/home/user/proj1", "id": "a1"},
{"root": "/home/user/proj2", "id": "b2"}
]`)

roots := extractWorkspaceRoots(data)
if len(roots) != 2 {
t.Fatalf("expected 2 roots, got %d: %v", len(roots), roots)
}

sort.Strings(roots)
if roots[0] != "/home/user/proj1" || roots[1] != "/home/user/proj2" {
t.Errorf("unexpected roots: %v", roots)
}
}

func TestExtractWorkspaceRoots_LegacyFlatMap(t *testing.T) {
data := []byte(`{
"/home/user/old-project": {"name": "old"},
"/var/www/site": {"name": "site"}
}`)

roots := extractWorkspaceRoots(data)
if len(roots) != 2 {
t.Fatalf("expected 2 roots, got %d: %v", len(roots), roots)
}

sort.Strings(roots)
if roots[0] != "/home/user/old-project" || roots[1] != "/var/www/site" {
t.Errorf("unexpected roots: %v", roots)
}
}

func TestExtractWorkspaceRoots_EmptyV2(t *testing.T) {
data := []byte(`{"version": "v2", "entries": []}`)

roots := extractWorkspaceRoots(data)
if roots != nil {
t.Errorf("expected nil for empty V2, got %v", roots)
}
}

func TestExtractWorkspaceRoots_InvalidJSON(t *testing.T) {
data := []byte(`{not valid json at all`)

roots := extractWorkspaceRoots(data)
if roots != nil {
t.Errorf("expected nil for invalid JSON, got %v", roots)
}
}

func TestExtractWorkspaceRoots_V2SkipsEmptyRoots(t *testing.T) {
data := []byte(`{
"version": "v2",
"entries": [
{"root": "/valid/path", "id": "x"},
{"root": "", "id": "y"},
{"root": "/another", "id": "z"}
]
}`)

roots := extractWorkspaceRoots(data)
if len(roots) != 2 {
t.Fatalf("expected 2 roots (skipping empty), got %d: %v", len(roots), roots)
}
}

func TestCleanWorkspaceData_WithV2Registry(t *testing.T) {
// Create a temp "home" directory
home := t.TempDir()

// Create fake .ragcode install dir with registry
installDir := filepath.Join(home, ".ragcode")
if err := os.MkdirAll(installDir, 0755); err != nil {
t.Fatal(err)
}

// Create two fake project directories with .ragcode inside
proj1 := filepath.Join(home, "projects", "app1")
proj2 := filepath.Join(home, "projects", "app2")
proj3 := filepath.Join(home, "projects", "app3") // not in registry

for _, p := range []string{proj1, proj2, proj3} {
ragDir := filepath.Join(p, ".ragcode")
if err := os.MkdirAll(ragDir, 0755); err != nil {
t.Fatal(err)
}
// Create a file inside to verify full removal
if err := os.WriteFile(filepath.Join(ragDir, "state.json"), []byte("{}"), 0644); err != nil {
t.Fatal(err)
}
}

// Write V2 registry with proj1 and proj2 (NOT proj3)
registry := map[string]interface{}{
"version": "v2",
"entries": []map[string]string{
{"root": proj1, "id": "aaa"},
{"root": proj2, "id": "bbb"},
},
}
regData, _ := json.MarshalIndent(registry, "", " ")
if err := os.WriteFile(filepath.Join(installDir, "registry.json"), regData, 0644); err != nil {
t.Fatal(err)
}

// Run the function
cleanWorkspaceData(home)

// proj1/.ragcode should be gone
if _, err := os.Stat(filepath.Join(proj1, ".ragcode")); !os.IsNotExist(err) {
t.Errorf("proj1/.ragcode should have been removed")
}

// proj2/.ragcode should be gone
if _, err := os.Stat(filepath.Join(proj2, ".ragcode")); !os.IsNotExist(err) {
t.Errorf("proj2/.ragcode should have been removed")
}

// proj3/.ragcode should still exist (not in registry)
if _, err := os.Stat(filepath.Join(proj3, ".ragcode")); os.IsNotExist(err) {
t.Errorf("proj3/.ragcode should NOT have been removed (not in registry)")
}
}
8 changes: 8 additions & 0 deletions internal/updater/updater_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
Expand Down Expand Up @@ -73,6 +74,9 @@ func TestFetchRemoteStableModel(t *testing.T) {

model, err := fetchRemoteStableModel(ctx)
if err != nil {
if strings.Contains(err.Error(), "status 403") {
t.Skip("Skipping: GitHub API rate limit (403)")
}
t.Fatalf("fetchRemoteStableModel failed: %v", err)
}

Expand All @@ -96,6 +100,10 @@ func TestCheckForUpdates(t *testing.T) {
// Testing with a very old version to trigger the update logic
info, err := CheckForUpdates(ctx, "0.0.1", true)
if err != nil {
// GitHub API rate-limits unauthenticated requests (403) — skip in CI
if strings.Contains(err.Error(), "status 403") {
t.Skip("Skipping: GitHub API rate limit (403) — expected in CI without auth token")
}
t.Fatalf("CheckForUpdates failed: %v", err)
}

Expand Down
59 changes: 47 additions & 12 deletions pkg/parser/go/analyzer.go
Original file line number Diff line number Diff line change
Expand Up @@ -231,7 +231,7 @@ func (ca *CodeAnalyzer) analyzeFunctionDecl(fset *token.FileSet, fn *doc.Func, a
info.Parameters = ca.extractParameters(fn.Decl.Type.Params)
info.Returns = ca.extractReturns(fn.Decl.Type.Results)
}
info.Calls = ca.extractCallsFromAST(astBody)
info.Calls, info.TemplateFiles = ca.extractCallsFromAST(astBody)
} else if fn.Decl != nil {
// Fallback to doc.Func Decl (won't have Body)
// Extract position information
Expand Down Expand Up @@ -761,6 +761,24 @@ func convertPackageInfoToChunks(pi *PackageInfo) []CodeChunk {
Type: pkgParser.RelCalls,
})
}
// Template file dependencies: template.ParseFiles("layout.html") → RelDependency
for _, tplFile := range fn.TemplateFiles {
rels = append(rels, pkgParser.Relation{
TargetName: tplFile,
Type: pkgParser.RelDependency,
})
}

metadata := map[string]any{
"receiver": fn.Receiver,
"is_method": fn.IsMethod,
"params": fn.Parameters,
"returns": fn.Returns,
"examples": fn.Examples,
}
if len(fn.TemplateFiles) > 0 {
metadata["template_files"] = fn.TemplateFiles
}

out = append(out, CodeChunk{
Type: kind,
Expand All @@ -774,13 +792,7 @@ func convertPackageInfoToChunks(pi *PackageInfo) []CodeChunk {
Docstring: fn.Description,
Code: fn.Code,
Relations: rels,
Metadata: map[string]any{
"receiver": fn.Receiver,
"is_method": fn.IsMethod,
"params": fn.Parameters,
"returns": fn.Returns,
"examples": fn.Examples,
},
Metadata: metadata,
})
}

Expand Down Expand Up @@ -925,29 +937,52 @@ func (ca *CodeAnalyzer) extractCodeFromFile(filePath string, startLine, endLine
return strings.Join(lines, "\n"), nil
}

func (ca *CodeAnalyzer) extractCallsFromAST(body *ast.BlockStmt) []string {
func (ca *CodeAnalyzer) extractCallsFromAST(body *ast.BlockStmt) ([]string, []string) {
if body == nil {
return nil
return nil, nil
}
var calls []string
var templateFiles []string
seen := make(map[string]bool)
seenTpl := make(map[string]bool)

// Template-related function names that receive file paths as string arguments.
templateFuncs := map[string]bool{
"ParseFiles": true, "ParseGlob": true,
}

ast.Inspect(body, func(n ast.Node) bool {
if call, ok := n.(*ast.CallExpr); ok {
var name string
var sel string // selector/method part (e.g. "ParseFiles" in template.ParseFiles)
switch fun := call.Fun.(type) {
case *ast.Ident:
name = fun.Name
case *ast.SelectorExpr:
x := ca.typeToString(fun.X)
name = fmt.Sprintf("%s.%s", x, fun.Sel.Name)
sel = fun.Sel.Name
name = fmt.Sprintf("%s.%s", x, sel)
}
if name != "" && !seen[name] {
calls = append(calls, name)
seen[name] = true
}

// Extract template file paths from template.ParseFiles("a.html", "b.html")
// and similar calls (works on any receiver: t.ParseFiles, tpl.ParseFiles, etc.)
if templateFuncs[sel] {
for _, arg := range call.Args {
Comment on lines 956 to +974
Copy link

Copilot AI Mar 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Template file dependency extraction only runs when the call expression is a selector (because it checks templateFuncs[sel]). Calls like ParseFiles("a.html") / ParseGlob("*.tmpl") invoked via an identifier (e.g., dot-import, wrapper funcs, or local alias) will never populate TemplateFiles. Consider also checking the *ast.Ident case (e.g., treat fun.Name as the selector for dependency extraction) so ident calls can be captured too.

Copilot uses AI. Check for mistakes.
if lit, ok := arg.(*ast.BasicLit); ok && lit.Kind == token.STRING {
path := strings.Trim(lit.Value, "\"`")
if path != "" && !seenTpl[path] {
seenTpl[path] = true
templateFiles = append(templateFiles, path)
}
}
}
}
}
return true
})
return calls
return calls, templateFiles
}
Loading
Loading