From e0f17d65c0d7a1ff881c382f483b3af3a57a5d2b Mon Sep 17 00:00:00 2001 From: Rene Leonhardt <65483435+reneleonhardt@users.noreply.github.com> Date: Tue, 28 Jul 2026 21:45:50 +0200 Subject: [PATCH 1/2] fix(scanner): Resolve Rust workspace imports Co-Authored-By: GPT-5.6 Sol --- blast_radius.go | 33 ++- cmd/hooks.go | 5 +- cmd/intent.go | 20 +- mcp/main.go | 19 +- scanner/astgrep.go | 42 +++- scanner/filegraph.go | 33 ++- scanner/rustgraph.go | 356 +++++++++++++++++++++++++++++++++ scanner/sg-rules/rust.yml | 13 +- scanner/types.go | 34 ++-- skills/builtin/config-setup.md | 4 + watch/events.go | 1 + watch/types.go | 15 +- 12 files changed, 525 insertions(+), 50 deletions(-) create mode 100644 scanner/rustgraph.go diff --git a/blast_radius.go b/blast_radius.go index 4f9e580..d75959b 100644 --- a/blast_radius.go +++ b/blast_radius.go @@ -81,6 +81,8 @@ type blastRadiusImporters struct { ImportersTotal int `json:"importers_total"` ImportsTotal int `json:"imports_total"` HubImportsTotal int `json:"hub_imports_total"` + CoverageStatus string `json:"coverage_status,omitempty"` + CoverageNotes []string `json:"coverage_notes,omitempty"` } type blastRadiusHighest struct { @@ -612,6 +614,8 @@ func capBlastRadiusImportersReport(report scanner.ImportersReport, max int) blas ImportersTotal: len(report.Importers), ImportsTotal: len(report.Imports), HubImportsTotal: len(report.HubImports), + CoverageStatus: report.CoverageStatus, + CoverageNotes: append([]string(nil), report.CoverageNotes...), } } @@ -1318,6 +1322,19 @@ func renderImportersReport(w io.Writer, report scanner.ImportersReport) { } fmt.Fprintf(w, " Imports %d hub(s): %s\n", len(report.HubImports), strings.Join(report.HubImports, ", ")) } + + renderCoverage(w, report.CoverageStatus, report.CoverageNotes) +} + +func renderCoverage(w io.Writer, status string, notes []string) { + if status == "" { + return + } + if len(notes) == 0 { + fmt.Fprintf(w, "Coverage: %s\n", status) + return + } + fmt.Fprintf(w, "Coverage: %s — %s\n", status, strings.Join(notes, "; ")) } func buildImportersReportFromGraph(root, file string, fg *scanner.FileGraph) scanner.ImportersReport { @@ -1334,13 +1351,15 @@ func buildImportersReportFromGraph(root, file string, fg *scanner.FileGraph) sca imports := append([]string(nil), fg.Imports[file]...) report := scanner.ImportersReport{ - Root: root, - Mode: "importers", - File: file, - Importers: importers, - Imports: imports, - ImporterCount: len(importers), - IsHub: fg.IsHub(file), + Root: root, + Mode: "importers", + File: file, + Importers: importers, + Imports: imports, + ImporterCount: len(importers), + IsHub: fg.IsHub(file), + CoverageStatus: fg.Coverage.Status, + CoverageNotes: append([]string(nil), fg.Coverage.Notes...), } for _, imp := range imports { diff --git a/cmd/hooks.go b/cmd/hooks.go index 497be6f..7260309 100644 --- a/cmd/hooks.go +++ b/cmd/hooks.go @@ -29,6 +29,7 @@ type hubInfo struct { Hubs []string Importers map[string][]string Imports map[string][]string + Coverage scanner.GraphCoverage } const ( @@ -115,13 +116,14 @@ func getHubInfoWithFallback(root string, allowFallback bool) *hubInfo { if state := watch.ReadState(root); state != nil { // State may contain file/event info only (no dependency graph) on very // large repos. Avoid expensive fallback scans in that case. - if len(state.Importers) == 0 && len(state.Imports) == 0 && len(state.Hubs) == 0 { + if len(state.Importers) == 0 && len(state.Imports) == 0 && len(state.Hubs) == 0 && state.Coverage.Status == "" { return nil } return &hubInfo{ Hubs: state.Hubs, Importers: state.Importers, Imports: state.Imports, + Coverage: state.Coverage, } } @@ -144,6 +146,7 @@ func getHubInfoWithFallback(root string, allowFallback bool) *hubInfo { Hubs: fg.HubFiles(), Importers: fg.Importers, Imports: fg.Imports, + Coverage: fg.Coverage, } } diff --git a/cmd/intent.go b/cmd/intent.go index ab871a0..a6621e8 100644 --- a/cmd/intent.go +++ b/cmd/intent.go @@ -10,13 +10,15 @@ import ( // TaskIntent represents a parsed understanding of what the user wants to do. type TaskIntent struct { - Category string `json:"category"` // "refactor", "feature", "bugfix", "explore", "test", "docs" - Confidence float64 `json:"confidence"` // 0.0-1.0 confidence in category - Files []string `json:"files"` // mentioned files - Subsystems []string `json:"subsystems"` // matched subsystem IDs - Scope string `json:"scope"` // "single-file", "package", "cross-cutting" - RiskLevel string `json:"risk"` // "low", "medium", "high" - Suggestions []ContextSuggestion `json:"suggestions"` // what to read/check next + Category string `json:"category"` // "refactor", "feature", "bugfix", "explore", "test", "docs" + Confidence float64 `json:"confidence"` // 0.0-1.0 confidence in category + Files []string `json:"files"` // mentioned files + Subsystems []string `json:"subsystems"` // matched subsystem IDs + Scope string `json:"scope"` // "single-file", "package", "cross-cutting" + RiskLevel string `json:"risk"` // "low", "medium", "high" + Suggestions []ContextSuggestion `json:"suggestions"` // what to read/check next + DependencyCoverage string `json:"dependency_coverage,omitempty"` + CoverageNotes []string `json:"coverage_notes,omitempty"` } // ContextSuggestion recommends a follow-up action based on code intelligence. @@ -114,6 +116,10 @@ func classifyIntent(prompt string, files []string, info *hubInfo, cfg config.Pro RiskLevel: "low", Scope: "single-file", } + if info != nil { + intent.DependencyCoverage = info.Coverage.Status + intent.CoverageNotes = append([]string(nil), info.Coverage.Notes...) + } // Score each category using weighted signals promptLower := strings.ToLower(prompt) diff --git a/mcp/main.go b/mcp/main.go index c83eac2..aa6705f 100644 --- a/mcp/main.go +++ b/mcp/main.go @@ -607,7 +607,7 @@ func handleGetImporters(ctx context.Context, req *mcp.CallToolRequest, input Imp importers := fg.Importers[input.File] if len(importers) == 0 { - return textResult("No files import '" + input.File + "'"), nil, nil + return textResult("No files import '" + input.File + "'" + mcpCoverageText(fg)), nil, nil } isHub := scanner.CountHubImporters(importers) >= scanner.HubThreshold @@ -616,7 +616,17 @@ func handleGetImporters(ctx context.Context, req *mcp.CallToolRequest, input Imp hubNote = " ⚠️ HUB FILE" } - return textResult(fmt.Sprintf("%d files import '%s':%s\n%s", len(importers), input.File, hubNote, strings.Join(importers, "\n"))), nil, nil + return textResult(fmt.Sprintf("%d files import '%s':%s\n%s%s", len(importers), input.File, hubNote, strings.Join(importers, "\n"), mcpCoverageText(fg))), nil, nil +} + +func mcpCoverageText(fg *scanner.FileGraph) string { + if fg == nil || fg.Coverage.Status == "" { + return "" + } + if len(fg.Coverage.Notes) == 0 { + return "\n\nCoverage: " + fg.Coverage.Status + } + return "\n\nCoverage: " + fg.Coverage.Status + " — " + strings.Join(fg.Coverage.Notes, "; ") } func handleGetHandoff(ctx context.Context, req *mcp.CallToolRequest, input HandoffInput) (*mcp.CallToolResult, any, error) { @@ -977,7 +987,7 @@ func handleGetHubs(ctx context.Context, req *mcp.CallToolRequest, input PathInpu hubs := fg.HubFiles() if len(hubs) == 0 { - return textResult("No hub files found (no files with 3+ importers)."), nil, nil + return textResult("No hub files found (no files with 3+ importers)." + mcpCoverageText(fg)), nil, nil } // Sort by importer count @@ -1002,7 +1012,7 @@ func handleGetHubs(ctx context.Context, req *mcp.CallToolRequest, input PathInpu } } - return textResult(sb.String()), nil, nil + return textResult(sb.String() + mcpCoverageText(fg)), nil, nil } func handleGetFileContext(ctx context.Context, req *mcp.CallToolRequest, input ImportersInput) (*mcp.CallToolResult, any, error) { @@ -1050,6 +1060,7 @@ func handleGetFileContext(ctx context.Context, req *mcp.CallToolRequest, input I // Connected files summary sb.WriteString(fmt.Sprintf("CONNECTED: %d files in dependency graph\n", len(connected))) + sb.WriteString(mcpCoverageText(fg)) return textResult(sb.String()), nil, nil } diff --git a/scanner/astgrep.go b/scanner/astgrep.go index d0d5dac..ceb86b8 100644 --- a/scanner/astgrep.go +++ b/scanner/astgrep.go @@ -287,7 +287,33 @@ func (s *AstGrepScanner) ScanDirectory(root string) ([]FileAnalysis, error) { } } - if strings.HasSuffix(m.RuleID, "-imports") { + if m.RuleID == "rust-mod-imports" || m.RuleID == "rust-path-imports" || m.RuleID == "rust-use-imports" { + var path string + kind := "rust-path" + switch m.RuleID { + case "rust-mod-imports": + kind = "rust-module" + if pathVar, ok := m.MetaVariables.Single["PATH"]; ok { + path = pathVar.Text + } + case "rust-path-imports": + path = m.Text + case "rust-use-imports": + if pathVar, ok := m.MetaVariables.Single["PATH"]; ok { + path = pathVar.Text + } + } + if path != "" { + if m.RuleID != "rust-path-imports" { + fileMap[relPath].Imports = append(fileMap[relPath].Imports, path) + } + fileMap[relPath].References = append(fileMap[relPath].References, ImportReference{ + Path: path, + Kind: kind, + Line: m.Range.Start.Line, + }) + } + } else if strings.HasSuffix(m.RuleID, "-imports") { // Use metaVariable PATH if available, otherwise fall back to text extraction var mod string if pathVar, ok := m.MetaVariables.Single["PATH"]; ok && pathVar.Text != "" { @@ -312,12 +338,26 @@ func (s *AstGrepScanner) ScanDirectory(root string) ([]FileAnalysis, error) { for _, a := range fileMap { a.Functions = dedupe(a.Functions) a.Imports = dedupe(a.Imports) + a.References = dedupeImportReferences(a.References) results = append(results, *a) } return results, nil } +func dedupeImportReferences(refs []ImportReference) []ImportReference { + seen := make(map[ImportReference]bool) + result := make([]ImportReference, 0, len(refs)) + for _, ref := range refs { + if seen[ref] { + continue + } + seen[ref] = true + result = append(result, ref) + } + return result +} + // ruleIDToLang maps ast-grep rule ID prefixes to language names. // Must cover every prefix used in sg-rules/*.yml files. var ruleIDToLang = map[string]string{ diff --git a/scanner/filegraph.go b/scanner/filegraph.go index 01439ed..a5a2336 100644 --- a/scanner/filegraph.go +++ b/scanner/filegraph.go @@ -19,6 +19,7 @@ type FileGraph struct { Packages map[string][]string // package path -> files in that package PathAliases map[string][]string // TS/JS path aliases from tsconfig.json (e.g., "@modules/*" -> ["src/modules/*"]) BaseURL string // TS/JS baseUrl from tsconfig.json + Coverage GraphCoverage } // fileIndex provides fast lookup of files by various import-like keys @@ -73,6 +74,7 @@ func BuildFileGraphFromFilteredAnalyses(root string, analyses []FileAnalysis, fi // Detect path aliases from tsconfig.json (for TS/JS import resolution) fg.PathAliases, fg.BaseURL = detectPathAliases(absRoot) + rustWorkspace := buildRustWorkspaceIndex(absRoot) // Scan all files with the same filters used for the analyses. gitCache := NewGitIgnoreCache(root) @@ -84,21 +86,32 @@ func BuildFileGraphFromFilteredAnalyses(root string, analyses []FileAnalysis, fi // Build file index for fast fuzzy matching idx := buildFileIndex(files, fg.Module) fg.Packages = idx.goPkgs + for _, file := range files { + if strings.EqualFold(filepath.Ext(file.Path), ".rs") { + fg.Coverage = GraphCoverage{Status: rustCoverageStatus, Notes: []string{rustCoverageNote}} + break + } + } // Resolve imports to files using universal fuzzy matching for _, a := range analyses { var resolvedImports []string - for _, imp := range a.Imports { - resolved := fuzzyResolve(imp, a.Path, idx, fg.Module, fg.PathAliases, fg.BaseURL) - // Exclude multi-file Go package imports to avoid inflating hub counts. - // Go package imports start with the module prefix and resolve to all - // files in that package. For all other imports (e.g., C# namespace - // imports that resolve via directory matching), allow multi-file - // resolution so inter-namespace dependencies are tracked. - isGoPkg := fg.Module != "" && strings.HasPrefix(imp, fg.Module) && len(resolved) > 1 - if !isGoPkg && len(resolved) > 0 { - resolvedImports = append(resolvedImports, resolved...) + if a.Language == "rust" { + resolvedImports = resolveRustReferences(absRoot, a, idx, rustWorkspace) + } else { + + for _, imp := range a.Imports { + resolved := fuzzyResolve(imp, a.Path, idx, fg.Module, fg.PathAliases, fg.BaseURL) + // Exclude multi-file Go package imports to avoid inflating hub counts. + // Go package imports start with the module prefix and resolve to all + // files in that package. For all other imports (e.g., C# namespace + // imports that resolve via directory matching), allow multi-file + // resolution so inter-namespace dependencies are tracked. + isGoPkg := fg.Module != "" && strings.HasPrefix(imp, fg.Module) && len(resolved) > 1 + if !isGoPkg && len(resolved) > 0 { + resolvedImports = append(resolvedImports, resolved...) + } } } diff --git a/scanner/rustgraph.go b/scanner/rustgraph.go new file mode 100644 index 0000000..f43c32e --- /dev/null +++ b/scanner/rustgraph.go @@ -0,0 +1,356 @@ +package scanner + +import ( + "bufio" + "os" + "path/filepath" + "sort" + "strconv" + "strings" +) + +const ( + rustCoverageStatus = "partial" + rustCoverageNote = "Rust macro-generated, string-routed, renamed-dependency, and #[path] module edges may be unresolved" +) + +// GraphCoverage describes known dependency-graph blind spots. +type GraphCoverage struct { + Status string `json:"status,omitempty"` + Notes []string `json:"notes,omitempty"` +} + +type rustPackage struct { + crateID string + root string + sourceDir string + libFile string +} + +type rustWorkspaceIndex struct { + byCrateID map[string][]rustPackage + packages []rustPackage +} + +type cargoManifest struct { + Package struct { + Name string `toml:"name"` + } `toml:"package"` + Workspace struct { + Members []string `toml:"members"` + Exclude []string `toml:"exclude"` + } `toml:"workspace"` + Lib struct { + Path string `toml:"path"` + } `toml:"lib"` +} + +func buildRustWorkspaceIndex(root string) *rustWorkspaceIndex { + index := &rustWorkspaceIndex{byCrateID: make(map[string][]rustPackage)} + rootManifest, ok := readCargoManifest(filepath.Join(root, "Cargo.toml")) + if !ok { + return index + } + + memberDirs := []string{} + if rootManifest.Package.Name != "" { + memberDirs = append(memberDirs, ".") + } + for _, member := range rootManifest.Workspace.Members { + matches, err := filepath.Glob(filepath.Join(root, filepath.FromSlash(member))) + if err != nil { + continue + } + for _, match := range matches { + rel, err := filepath.Rel(root, match) + if err == nil { + memberDirs = append(memberDirs, rel) + } + } + } + + excluded := make(map[string]bool) + for _, pattern := range rootManifest.Workspace.Exclude { + matches, err := filepath.Glob(filepath.Join(root, filepath.FromSlash(pattern))) + if err != nil { + continue + } + for _, match := range matches { + if rel, err := filepath.Rel(root, match); err == nil { + excluded[filepath.Clean(rel)] = true + } + } + } + + for _, memberDir := range dedupe(memberDirs) { + memberDir = filepath.Clean(memberDir) + if excluded[memberDir] { + continue + } + manifest, ok := readCargoManifest(filepath.Join(root, memberDir, "Cargo.toml")) + if !ok || manifest.Package.Name == "" { + continue + } + libFile := manifest.Lib.Path + if libFile == "" { + libFile = filepath.Join("src", "lib.rs") + } + libFile = filepath.Clean(filepath.Join(memberDir, libFile)) + pkg := rustPackage{ + crateID: strings.ReplaceAll(manifest.Package.Name, "-", "_"), + root: memberDir, + sourceDir: filepath.Dir(libFile), + libFile: libFile, + } + index.packages = append(index.packages, pkg) + index.byCrateID[pkg.crateID] = append(index.byCrateID[pkg.crateID], pkg) + } + + sort.Slice(index.packages, func(i, j int) bool { + return len(index.packages[i].root) > len(index.packages[j].root) + }) + return index +} + +func readCargoManifest(path string) (cargoManifest, bool) { + var manifest cargoManifest + file, err := os.Open(path) + if err != nil { + return cargoManifest{}, false + } + defer file.Close() + + section := "" + var pendingKey string + var pendingValue strings.Builder + apply := func(key, value string) { + switch { + case section == "package" && key == "name": + manifest.Package.Name = parseCargoString(value) + case section == "workspace" && key == "members": + manifest.Workspace.Members = parseCargoStringArray(value) + case section == "workspace" && key == "exclude": + manifest.Workspace.Exclude = parseCargoStringArray(value) + case section == "lib" && key == "path": + manifest.Lib.Path = parseCargoString(value) + } + } + + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := strings.TrimSpace(stripCargoComment(scanner.Text())) + if pendingKey != "" { + pendingValue.WriteString(line) + if strings.Contains(line, "]") { + apply(pendingKey, pendingValue.String()) + pendingKey = "" + pendingValue.Reset() + } + continue + } + if line == "" { + continue + } + if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") { + section = strings.TrimSpace(strings.Trim(line, "[]")) + continue + } + key, value, ok := strings.Cut(line, "=") + if !ok { + continue + } + key, value = strings.TrimSpace(key), strings.TrimSpace(value) + if strings.HasPrefix(value, "[") && !strings.Contains(value, "]") { + pendingKey = key + pendingValue.WriteString(value) + continue + } + apply(key, value) + } + if scanner.Err() != nil { + return cargoManifest{}, false + } + return manifest, true +} + +func stripCargoComment(line string) string { + inString := false + escaped := false + for i, r := range line { + if escaped { + escaped = false + continue + } + if r == '\\' && inString { + escaped = true + continue + } + if r == '"' { + inString = !inString + continue + } + if r == '#' && !inString { + return line[:i] + } + } + return line +} + +func parseCargoString(value string) string { + value = strings.TrimSpace(strings.TrimSuffix(value, ",")) + parsed, err := strconv.Unquote(value) + if err != nil { + return "" + } + return parsed +} + +func parseCargoStringArray(value string) []string { + value = strings.TrimSpace(value) + value = strings.TrimPrefix(value, "[") + value = strings.TrimSuffix(value, "]") + var result []string + for _, item := range strings.Split(value, ",") { + if parsed := parseCargoString(item); parsed != "" { + result = append(result, parsed) + } + } + return result +} + +func resolveRustReferences(root string, analysis FileAnalysis, idx *fileIndex, workspace *rustWorkspaceIndex) []string { + var resolved []string + for _, ref := range analysis.References { + switch ref.Kind { + case "rust-module": + if rustModuleUsesPathAttribute(root, analysis.Path, ref.Line) { + continue + } + if target := resolveRustModule(ref.Path, analysis.Path, idx); target != "" { + resolved = append(resolved, target) + } + case "rust-path": + if target := resolveRustPath(ref.Path, analysis.Path, idx, workspace); target != "" { + resolved = append(resolved, target) + } + } + } + return dedupe(resolved) +} + +func resolveRustModule(name, fromFile string, idx *fileIndex) string { + dir := rustChildModuleDir(fromFile) + for _, candidate := range []string{ + filepath.Join(dir, name+".rs"), + filepath.Join(dir, name, "mod.rs"), + } { + if files := idx.byExact[candidate]; len(files) == 1 { + return files[0] + } + } + return "" +} + +func rustChildModuleDir(path string) string { + dir := filepath.Dir(path) + base := filepath.Base(path) + switch base { + case "lib.rs", "main.rs", "mod.rs": + return dir + default: + return filepath.Join(dir, strings.TrimSuffix(base, filepath.Ext(base))) + } +} + +func resolveRustPath(path, fromFile string, idx *fileIndex, workspace *rustWorkspaceIndex) string { + path = strings.TrimPrefix(strings.TrimSpace(path), "::") + parts := strings.Split(path, "::") + if len(parts) < 2 { + return "" + } + + var base string + rootFile := "" + switch parts[0] { + case "crate": + pkg, ok := workspace.packageForFile(fromFile) + if !ok { + return "" + } + base = pkg.sourceDir + rootFile = pkg.libFile + parts = parts[1:] + case "self": + base = rustChildModuleDir(fromFile) + parts = parts[1:] + case "super": + base = rustChildModuleDir(fromFile) + for len(parts) > 0 && parts[0] == "super" { + base = filepath.Dir(base) + parts = parts[1:] + } + default: + packages := workspace.byCrateID[parts[0]] + if len(packages) != 1 { + return "" + } + base = packages[0].sourceDir + rootFile = packages[0].libFile + parts = parts[1:] + } + + for i := len(parts); i > 0; i-- { + modulePath := filepath.Join(append([]string{base}, parts[:i]...)...) + for _, candidate := range []string{modulePath + ".rs", filepath.Join(modulePath, "mod.rs")} { + if files := idx.byExact[candidate]; len(files) == 1 { + return files[0] + } + } + } + if rootFile != "" { + if files := idx.byExact[rootFile]; len(files) == 1 { + return files[0] + } + } + return "" +} + +func (index *rustWorkspaceIndex) packageForFile(path string) (rustPackage, bool) { + path = filepath.Clean(path) + var rootPackage *rustPackage + for _, pkg := range index.packages { + if pkg.root == "." { + candidate := pkg + rootPackage = &candidate + continue + } + if path == pkg.root || strings.HasPrefix(path, pkg.root+string(filepath.Separator)) { + return pkg, true + } + } + if rootPackage != nil { + return *rootPackage, true + } + return rustPackage{}, false +} + +func rustModuleUsesPathAttribute(root, file string, line int) bool { + data, err := os.ReadFile(filepath.Join(root, file)) + if err != nil { + return false + } + lines := strings.Split(string(data), "\n") + for i := line - 1; i >= 0; i-- { + trimmed := strings.TrimSpace(lines[i]) + if trimmed == "" { + continue + } + if strings.HasPrefix(trimmed, "#[path") { + return true + } + if !strings.HasPrefix(trimmed, "#[") { + return false + } + } + return false +} diff --git a/scanner/sg-rules/rust.yml b/scanner/sg-rules/rust.yml index 32fd6f6..72adc57 100644 --- a/scanner/sg-rules/rust.yml +++ b/scanner/sg-rules/rust.yml @@ -1,10 +1,21 @@ -id: rust-imports +id: rust-use-imports language: rust rule: any: - pattern: use $PATH; - pattern: use $PATH::$$$_; +--- +id: rust-mod-imports +language: rust +rule: + any: - pattern: mod $PATH; + - pattern: pub mod $PATH; +--- +id: rust-path-imports +language: rust +rule: + pattern: $PATH::$$$REST --- id: rust-functions language: rust diff --git a/scanner/types.go b/scanner/types.go index bf94346..4e37d08 100644 --- a/scanner/types.go +++ b/scanner/types.go @@ -33,10 +33,18 @@ type Project struct { // FileAnalysis holds extracted info about a single file for deps mode. type FileAnalysis struct { - Path string `json:"path"` - Language string `json:"language"` - Functions []string `json:"functions"` - Imports []string `json:"imports"` + Path string `json:"path"` + Language string `json:"language"` + Functions []string `json:"functions"` + Imports []string `json:"imports"` + References []ImportReference `json:"-"` +} + +// ImportReference preserves language-specific import semantics for graph resolution. +type ImportReference struct { + Path string + Kind string + Line int } // DepsProject is the JSON output for --deps mode. @@ -50,14 +58,16 @@ type DepsProject struct { // ImportersReport is the JSON output for --importers mode. type ImportersReport struct { - Root string `json:"root"` - Mode string `json:"mode"` - File string `json:"file"` - Importers []string `json:"importers"` - Imports []string `json:"imports,omitempty"` - HubImports []string `json:"hub_imports,omitempty"` - ImporterCount int `json:"importer_count"` - IsHub bool `json:"is_hub"` + Root string `json:"root"` + Mode string `json:"mode"` + File string `json:"file"` + Importers []string `json:"importers"` + Imports []string `json:"imports,omitempty"` + HubImports []string `json:"hub_imports,omitempty"` + ImporterCount int `json:"importer_count"` + IsHub bool `json:"is_hub"` + CoverageStatus string `json:"coverage_status,omitempty"` + CoverageNotes []string `json:"coverage_notes,omitempty"` } // extToLang maps file extensions to language names diff --git a/skills/builtin/config-setup.md b/skills/builtin/config-setup.md index 86d1197..e9649fd 100644 --- a/skills/builtin/config-setup.md +++ b/skills/builtin/config-setup.md @@ -22,6 +22,8 @@ Write or improve `.codemap/config.json` so future Codemap calls stay focused on ## Workflow 1. Inspect the repo quickly before writing config + - Read the root and applicable nested `AGENTS.md` files before deciding ownership or scope + - Read language workspace manifests such as `Cargo.toml`; preserve every declared workspace member - Run `codemap .` - If needed, run `codemap --deps .` - Note the stack markers (`Cargo.toml`, `Package.swift`, `*.xcodeproj`, `go.mod`, `package.json`, `pyproject.toml`, etc.) @@ -40,6 +42,7 @@ Write or improve `.codemap/config.json` so future Codemap calls stay focused on 4. Prefer stack-aware defaults - Rust: focus `src`, `tests`, `benches`, `examples`; de-prioritize corpora, sample PDFs, training data, large generated artifacts + - For Cargo workspaces, keep all workspace members' `src`, `tests`, `benches`, and `examples` trees unless repository evidence explicitly excludes one - iOS/Swift: focus app/framework source, tests, package/project manifests; de-prioritize `.xcassets`, screenshots, snapshots, vendor/build outputs - TS/JS: focus `src`, `apps`, `packages`, `tests`; de-prioritize `dist`, `coverage`, Storybook assets, large fixture payloads - Python: focus package roots, tests, tool config; de-prioritize notebooks, data dumps, models, fixtures when they overwhelm code @@ -53,6 +56,7 @@ Write or improve `.codemap/config.json` so future Codemap calls stay focused on - Rerun `codemap .` - If the repo still looks noisy, refine `exclude` and possibly `depth` - Only rerun `codemap --deps .` after tree output looks reasonable + - Verify at least one known cross-layer dependency remains discoverable after tuning ## Heuristics diff --git a/watch/events.go b/watch/events.go index f509d94..50271bb 100644 --- a/watch/events.go +++ b/watch/events.go @@ -565,6 +565,7 @@ func (d *Daemon) writeState() { state.Hubs = d.graph.FileGraph.HubFiles() state.Importers = d.graph.FileGraph.Importers state.Imports = d.graph.FileGraph.Imports + state.Coverage = d.graph.FileGraph.Coverage } data, err := json.MarshalIndent(state, "", " ") diff --git a/watch/types.go b/watch/types.go index 174b779..502996a 100644 --- a/watch/types.go +++ b/watch/types.go @@ -54,11 +54,12 @@ type Graph struct { // State represents the daemon state that hooks can read type State struct { - UpdatedAt time.Time `json:"updated_at"` - FileCount int `json:"file_count"` - Hubs []string `json:"hubs"` - Importers map[string][]string `json:"importers"` // file -> files that import it - Imports map[string][]string `json:"imports"` // file -> files it imports - RecentEvents []Event `json:"recent_events"` // last 50 events for timeline - WorkingSet *WorkingSet `json:"working_set,omitempty"` + UpdatedAt time.Time `json:"updated_at"` + FileCount int `json:"file_count"` + Hubs []string `json:"hubs"` + Importers map[string][]string `json:"importers"` // file -> files that import it + Imports map[string][]string `json:"imports"` // file -> files it imports + RecentEvents []Event `json:"recent_events"` // last 50 events for timeline + WorkingSet *WorkingSet `json:"working_set,omitempty"` + Coverage scanner.GraphCoverage `json:"coverage,omitempty"` } From 47f53a600b6f2edd6d2f4c9a018a97b105956cdf Mon Sep 17 00:00:00 2001 From: Rene Leonhardt <65483435+reneleonhardt@users.noreply.github.com> Date: Tue, 28 Jul 2026 21:46:00 +0200 Subject: [PATCH 2/2] test(scanner): Cover Rust workspace imports Co-Authored-By: GPT-5.6 Sol --- blast_radius_fixes_test.go | 20 ++++++ mcp/main_more_test.go | 45 +++++++++++++ scanner/filegraph_test.go | 125 +++++++++++++++++++++++++++++++++++++ skills/loader_test.go | 33 ++++++++++ 4 files changed, 223 insertions(+) diff --git a/blast_radius_fixes_test.go b/blast_radius_fixes_test.go index ecd2cba..2ac559e 100644 --- a/blast_radius_fixes_test.go +++ b/blast_radius_fixes_test.go @@ -5,6 +5,7 @@ import ( "os" "os/exec" "path/filepath" + "reflect" "strings" "testing" @@ -251,6 +252,25 @@ func TestBuildImportersReportFromGraphPreservesScanOrder(t *testing.T) { } } +func TestBuildImportersReportFromGraphCarriesCoverage(t *testing.T) { + fg := &scanner.FileGraph{ + Imports: map[string][]string{}, + Importers: map[string][]string{}, + Coverage: scanner.GraphCoverage{ + Status: "partial", + Notes: []string{"dynamic routes unresolved"}, + }, + } + + report := buildImportersReportFromGraph("/repo", "x.rs", fg) + if report.CoverageStatus != "partial" || !reflect.DeepEqual(report.CoverageNotes, []string{"dynamic routes unresolved"}) { + t.Fatalf("report coverage = %q %#v", report.CoverageStatus, report.CoverageNotes) + } + if got := renderImportersReportString(report); !strings.Contains(got, "Coverage: partial") { + t.Fatalf("rendered report omits partial coverage:\n%s", got) + } +} + // Finding #9: changed files with no importers must not emit empty importer // sections. func TestBlastRadiusOmitsEmptyImporterSections(t *testing.T) { diff --git a/mcp/main_more_test.go b/mcp/main_more_test.go index 78881ee..f9cd663 100644 --- a/mcp/main_more_test.go +++ b/mcp/main_more_test.go @@ -341,3 +341,48 @@ func TestHandleGraphContextHandlers(t *testing.T) { } } } + +func TestRustGraphContextHandlersDisclosePartialCoverage(t *testing.T) { + if !scanner.NewAstGrepAnalyzer().Available() { + t.Skip("ast-grep not available") + } + + root := t.TempDir() + files := map[string]string{ + "Cargo.toml": "[package]\nname = \"demo\"\nversion = \"0.1.0\"\n", + "src/lib.rs": "mod workspace;\n", + "src/workspace.rs": "pub fn run() {}\n", + "src/string_route.rs": "const COMMAND: &str = \"run\";\n", + } + for path, content := range files { + full := filepath.Join(root, path) + if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(full, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + } + + importers, _, err := handleGetImporters(context.Background(), nil, ImportersInput{Path: root, File: "src/workspace.rs"}) + if err != nil { + t.Fatal(err) + } + hubs, _, err := handleGetHubs(context.Background(), nil, PathInput{Path: root}) + if err != nil { + t.Fatal(err) + } + fileContext, _, err := handleGetFileContext(context.Background(), nil, ImportersInput{Path: root, File: "src/workspace.rs"}) + if err != nil { + t.Fatal(err) + } + for name, result := range map[string]string{ + "importers": resultText(t, importers), + "hubs": resultText(t, hubs), + "file context": resultText(t, fileContext), + } { + if !strings.Contains(result, "Coverage: partial") { + t.Fatalf("%s MCP output omits partial coverage:\n%s", name, result) + } + } +} diff --git a/scanner/filegraph_test.go b/scanner/filegraph_test.go index b05393f..c57920f 100644 --- a/scanner/filegraph_test.go +++ b/scanner/filegraph_test.go @@ -4,10 +4,135 @@ import ( "os" "path/filepath" "reflect" + "slices" "sort" "testing" ) +func TestRustWorkspaceImportersRespectCrateBoundaries(t *testing.T) { + if !NewAstGrepAnalyzer().Available() { + t.Skip("ast-grep not available") + } + + root := t.TempDir() + files := map[string]string{ + "Cargo.toml": `[workspace] +members = ["crate-a", "but-api", "consumer"] +resolver = "2" +`, + "crate-a/Cargo.toml": `[package] +name = "crate-a" +version = "0.1.0" +edition = "2021" +`, + "crate-a/src/lib.rs": "mod workspace;\n", + "crate-a/src/workspace.rs": "pub fn local() {}\n", + "but-api/Cargo.toml": `[package] +name = "but-api" +version = "0.1.0" +edition = "2021" +`, + "but-api/src/lib.rs": "pub mod workspace;\n", + "but-api/src/workspace.rs": "pub fn run() {}\n", + "consumer/Cargo.toml": `[package] +name = "consumer" +version = "0.1.0" +edition = "2021" + +[dependencies] +but-api = { path = "../but-api" } +`, + "consumer/src/lib.rs": "pub fn call() { but_api::workspace::run(); }\n", + } + for path, content := range files { + full := filepath.Join(root, path) + if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(full, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + } + + graph, err := BuildFileGraph(root) + if err != nil { + t.Fatalf("BuildFileGraph() error: %v", err) + } + + want := []string{"but-api/src/lib.rs", "consumer/src/lib.rs"} + got := append([]string(nil), graph.Importers["but-api/src/workspace.rs"]...) + sort.Strings(got) + if !reflect.DeepEqual(got, want) { + t.Fatalf("but-api workspace importers = %#v, want %#v", got, want) + } + if got := graph.Imports["crate-a/src/lib.rs"]; !reflect.DeepEqual(got, []string{"crate-a/src/workspace.rs"}) { + t.Fatalf("crate-a module imports = %#v, want only its local workspace module", got) + } +} + +func TestRustWorkspaceResolvesLegalModulePathsAndReportsPartialCoverage(t *testing.T) { + if !NewAstGrepAnalyzer().Available() { + t.Skip("ast-grep not available") + } + + root := t.TempDir() + files := map[string]string{ + "Cargo.toml": `[package] +name = "root-crate" +version = "0.1.0" +edition = "2021" + +[workspace] +members = ["nested"] +`, + "src/lib.rs": "mod workspace;\nmod branch;\n#[path = \"custom.rs\"]\nmod redirected;\n", + "src/workspace.rs": "pub fn run() {}\n", + "src/branch.rs": "pub mod child;\npub fn call() { crate::workspace::run(); self::child::run(); }\n", + "src/branch/child.rs": "pub fn run() { super::super::workspace::run(); }\n", + "src/custom.rs": "pub fn run() {}\n", + "nested/Cargo.toml": `[package] +name = "nested" +version = "0.1.0" +edition = "2021" +`, + "nested/src/lib.rs": "mod workspace;\npub fn call() { crate::workspace::nested(); }\n", + "nested/src/workspace.rs": "pub fn nested() {}\n", + } + for path, content := range files { + full := filepath.Join(root, path) + if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(full, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + } + + graph, err := BuildFileGraph(root) + if err != nil { + t.Fatalf("BuildFileGraph() error: %v", err) + } + + if got := graph.Imports["nested/src/lib.rs"]; !reflect.DeepEqual(got, []string{"nested/src/workspace.rs"}) { + t.Fatalf("nested crate module imports = %#v, want its own workspace module", got) + } + wantBranch := []string{"src/branch/child.rs", "src/workspace.rs"} + gotBranch := append([]string(nil), graph.Imports["src/branch.rs"]...) + sort.Strings(gotBranch) + if !reflect.DeepEqual(gotBranch, wantBranch) { + t.Fatalf("branch imports = %#v, want %#v", gotBranch, wantBranch) + } + if got := graph.Imports["src/branch/child.rs"]; !reflect.DeepEqual(got, []string{"src/workspace.rs"}) { + t.Fatalf("repeated super path imports = %#v, want root workspace module", got) + } + if got := graph.Imports["src/lib.rs"]; slices.Contains(got, "src/custom.rs") { + t.Fatalf("#[path] module should remain unresolved, got imports %#v", got) + } + if graph.Coverage.Status != "partial" || len(graph.Coverage.Notes) == 0 { + t.Fatalf("coverage = %+v, want explicit partial Rust coverage", graph.Coverage) + } +} + func TestDetectLanguage(t *testing.T) { tests := []struct { name string diff --git a/skills/loader_test.go b/skills/loader_test.go index d5efa23..99841a0 100644 --- a/skills/loader_test.go +++ b/skills/loader_test.go @@ -3,6 +3,7 @@ package skills import ( "os" "path/filepath" + "strings" "testing" ) @@ -104,6 +105,38 @@ func TestLoadBuiltinSkills(t *testing.T) { } } +func TestConfigSetupSkillIncludesCargoWorkspaceCoverageGuidance(t *testing.T) { + skills, err := loadBuiltinSkills() + if err != nil { + t.Fatal(err) + } + var body string + for _, skill := range skills { + if skill.Meta.Name == "config-setup" { + body = skill.Body + break + } + } + + // Builtin skills are embedded runtime guidance. Keep these behavior + // requirements explicit without coupling the test to the document layout. + for _, check := range []struct { + name string + guidance string + }{ + {name: "reads agent instructions", guidance: "AGENTS.md"}, + {name: "reads Cargo workspace manifests", guidance: "Cargo.toml"}, + {name: "keeps workspace members", guidance: "workspace members"}, + {name: "checks cross-layer dependencies", guidance: "cross-layer dependency"}, + } { + t.Run(check.name, func(t *testing.T) { + if !strings.Contains(body, check.guidance) { + t.Fatalf("config-setup skill omits %q", check.guidance) + } + }) + } +} + func TestLoadSkillsFromDir(t *testing.T) { // Create temp dir with a skill dir := t.TempDir()