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
33 changes: 26 additions & 7 deletions blast_radius.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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...),
}
}

Expand Down Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down
20 changes: 20 additions & 0 deletions blast_radius_fixes_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"os"
"os/exec"
"path/filepath"
"reflect"
"strings"
"testing"

Expand Down Expand Up @@ -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) {
Expand Down
5 changes: 4 additions & 1 deletion cmd/hooks.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ type hubInfo struct {
Hubs []string
Importers map[string][]string
Imports map[string][]string
Coverage scanner.GraphCoverage
}

const (
Expand Down Expand Up @@ -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,
}
}

Expand All @@ -144,6 +146,7 @@ func getHubInfoWithFallback(root string, allowFallback bool) *hubInfo {
Hubs: fg.HubFiles(),
Importers: fg.Importers,
Imports: fg.Imports,
Coverage: fg.Coverage,
}
}

Expand Down
20 changes: 13 additions & 7 deletions cmd/intent.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Expand Down
19 changes: 15 additions & 4 deletions mcp/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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) {
Expand Down Expand Up @@ -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
Expand All @@ -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) {
Expand Down Expand Up @@ -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
}
Expand Down
45 changes: 45 additions & 0 deletions mcp/main_more_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}
42 changes: 41 additions & 1 deletion scanner/astgrep.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 != "" {
Expand All @@ -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{
Expand Down
33 changes: 23 additions & 10 deletions scanner/filegraph.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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...)
}
}
}

Expand Down
Loading
Loading