Skip to content

Commit 1ed7f8d

Browse files
JordanCoinclaude
andauthored
Fix hub detection accuracy and unify dependency resolution (#14)
- Fix false hub detection for package-level imports (Go, Python, Rust, etc.) Only count imports that resolve to exactly one file. Package/module imports that resolve to multiple files are now correctly skipped. - Fix atomic save "remove" artifacts in session history When editors do write-to-temp + rename, fsnotify sees REMOVE events. Now checks if "removed" files still exist and relabels them as "edited". - Unify dependency resolution by removing duplicate findInternalDeps() The render/depgraph.go had its own buggy import resolution that incorrectly parsed file extensions (e.g., "./utils/helper.js" → "js" instead of "helper"). Now uses scanner.BuildFileGraph() for consistent, accurate resolution across --deps output, hooks, MCP tools, and watch daemon. - Filter deps to displayed files in --diff mode When using --deps --diff, dependency counts and hub info now only reflect files in the diff, not the entire repo. - Remove ~130 lines of duplicate/buggy code from render/depgraph.go 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 289eff2 commit 1ed7f8d

3 files changed

Lines changed: 64 additions & 137 deletions

File tree

cmd/hooks.go

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ func hookSessionStart(root string) error {
109109

110110
// Show last session context if resuming work
111111
if len(lastSessionEvents) > 0 {
112-
showLastSessionContext(lastSessionEvents)
112+
showLastSessionContext(root, lastSessionEvents)
113113
}
114114

115115
return nil
@@ -167,7 +167,7 @@ func getLastSessionEvents(root string) []string {
167167
}
168168

169169
// showLastSessionContext displays what was worked on in previous session
170-
func showLastSessionContext(events []string) {
170+
func showLastSessionContext(root string, events []string) {
171171
// Extract unique files from events
172172
files := make(map[string]string) // file -> last operation
173173
for _, line := range events {
@@ -185,6 +185,16 @@ func showLastSessionContext(events []string) {
185185
return
186186
}
187187

188+
// Fix atomic save artifacts: editors often do write-to-temp + rename,
189+
// which fsnotify sees as REMOVE. If file still exists, it was edited.
190+
for file, op := range files {
191+
if strings.EqualFold(op, "REMOVE") || strings.EqualFold(op, "RENAME") {
192+
if _, err := os.Stat(filepath.Join(root, file)); err == nil {
193+
files[file] = "edited"
194+
}
195+
}
196+
}
197+
188198
fmt.Println()
189199
fmt.Println("🕐 Last session worked on:")
190200
count := 0

render/depgraph.go

Lines changed: 45 additions & 134 deletions
Original file line numberDiff line numberDiff line change
@@ -22,134 +22,6 @@ func titleCase(s string) string {
2222
return strings.Join(words, " ")
2323
}
2424

25-
// Language compatibility groups
26-
var langGroups = map[string]string{
27-
"python": "python",
28-
"go": "go",
29-
"javascript": "js",
30-
"typescript": "js",
31-
"rust": "rust",
32-
"ruby": "ruby",
33-
"c": "c",
34-
"cpp": "c",
35-
"java": "java",
36-
"swift": "swift",
37-
"bash": "bash",
38-
"kotlin": "kotlin",
39-
"csharp": "csharp",
40-
"php": "php",
41-
"lua": "lua",
42-
"scala": "scala",
43-
"elixir": "elixir",
44-
"solidity": "solidity",
45-
}
46-
47-
// Standard library names to filter out
48-
var stdlibNames = map[string]bool{
49-
// Go stdlib
50-
"errors": true, "fmt": true, "io": true, "os": true, "path": true, "sync": true, "time": true, "context": true, "http": true,
51-
"net": true, "bytes": true, "strings": true, "strconv": true, "sort": true, "flag": true, "log": true, "bufio": true,
52-
"encoding": true, "testing": true, "runtime": true, "unsafe": true, "reflect": true, "regexp": true,
53-
// Python stdlib
54-
"logging": true, "typing": true, "collections": true, "datetime": true, "json": true, "sys": true, "re": true,
55-
"pathlib": true, "hashlib": true, "base64": true, "asyncio": true, "enum": true, "functools": true, "random": true,
56-
"math": true, "copy": true, "itertools": true, "contextlib": true,
57-
// JS/TS common
58-
"fs": true, "util": true, "events": true, "stream": true, "crypto": true, "https": true,
59-
"react": true, "filepath": true, "embed": true,
60-
}
61-
62-
// normalizeImport normalizes an import string
63-
func normalizeImport(imp, lang string) string {
64-
imp = strings.Trim(imp, "\"'")
65-
if strings.Contains(imp, "/") {
66-
parts := strings.Split(imp, "/")
67-
imp = parts[len(parts)-1]
68-
}
69-
if strings.Contains(imp, ".") && !strings.HasPrefix(imp, ".") {
70-
parts := strings.Split(imp, ".")
71-
imp = parts[len(parts)-1]
72-
}
73-
// Remove file extensions
74-
extPattern := regexp.MustCompile(`\.(py|go|js|ts|jsx|tsx|rb|rs|c|h|cpp|hpp|java|swift)$`)
75-
imp = extPattern.ReplaceAllString(imp, "")
76-
return strings.ToLower(imp)
77-
}
78-
79-
// findInternalDeps finds which files import which other files
80-
func findInternalDeps(files []scanner.FileAnalysis) map[string][]string {
81-
// Build lookup: name -> list of (path, language_group)
82-
type fileInfo struct {
83-
path string
84-
langGroup string
85-
}
86-
nameToInfos := make(map[string][]fileInfo)
87-
88-
for _, f := range files {
89-
langGroup := langGroups[f.Language]
90-
if langGroup == "" {
91-
langGroup = f.Language
92-
}
93-
basename := filepath.Base(f.Path)
94-
extPattern := regexp.MustCompile(`\.[^.]+$`)
95-
name := strings.ToLower(extPattern.ReplaceAllString(basename, ""))
96-
nameToInfos[name] = append(nameToInfos[name], fileInfo{f.Path, langGroup})
97-
}
98-
99-
deps := make(map[string][]string)
100-
101-
for _, f := range files {
102-
srcLang := f.Language
103-
srcGroup := langGroups[srcLang]
104-
if srcGroup == "" {
105-
srcGroup = srcLang
106-
}
107-
108-
for _, imp := range f.Imports {
109-
// Skip stdlib-looking imports
110-
if !strings.Contains(imp, "/") && !strings.Contains(imp, ".") {
111-
if stdlibNames[strings.ToLower(imp)] {
112-
continue
113-
}
114-
}
115-
116-
norm := normalizeImport(imp, srcLang)
117-
if stdlibNames[norm] {
118-
continue
119-
}
120-
121-
if infos, ok := nameToInfos[norm]; ok {
122-
srcBasename := filepath.Base(f.Path)
123-
for _, info := range infos {
124-
if info.path == f.Path {
125-
continue // Skip self
126-
}
127-
if srcGroup != info.langGroup {
128-
continue // Skip cross-language
129-
}
130-
targetName := filepath.Base(info.path)
131-
if targetName == srcBasename {
132-
continue // Skip same-basename
133-
}
134-
// Check if already added
135-
found := false
136-
for _, d := range deps[f.Path] {
137-
if d == targetName {
138-
found = true
139-
break
140-
}
141-
}
142-
if !found {
143-
deps[f.Path] = append(deps[f.Path], targetName)
144-
}
145-
}
146-
}
147-
}
148-
}
149-
150-
return deps
151-
}
152-
15325
// getSystemName infers a system/component name from directory path
15426
func getSystemName(dirPath string) string {
15527
parts := strings.Split(strings.ReplaceAll(dirPath, "\\", "/"), "/")
@@ -195,14 +67,53 @@ func Depgraph(project scanner.DepsProject) {
19567
internalNames[name] = true
19668
}
19769

198-
internalDeps := findInternalDeps(files)
70+
// Use BuildFileGraph for accurate file-level dependency resolution
71+
fg, err := scanner.BuildFileGraph(project.Root)
72+
var internalDeps map[string][]string
73+
var depCounts map[string]int
74+
if err == nil && fg != nil {
75+
// Build set of files we're displaying (may be filtered by --diff)
76+
displayedFiles := make(map[string]bool)
77+
for _, f := range files {
78+
displayedFiles[f.Path] = true
79+
}
19980

200-
// Count dependencies on each file
201-
depCounts := make(map[string]int)
202-
for _, targets := range internalDeps {
203-
for _, target := range targets {
204-
depCounts[target]++
81+
// Filter imports to only include displayed files
82+
internalDeps = make(map[string][]string)
83+
for file, imports := range fg.Imports {
84+
if !displayedFiles[file] {
85+
continue
86+
}
87+
var filtered []string
88+
for _, imp := range imports {
89+
if displayedFiles[imp] {
90+
filtered = append(filtered, imp)
91+
}
92+
}
93+
if len(filtered) > 0 {
94+
internalDeps[file] = filtered
95+
}
96+
}
97+
98+
// Count importers only among displayed files
99+
depCounts = make(map[string]int)
100+
for file, importers := range fg.Importers {
101+
if !displayedFiles[file] {
102+
continue
103+
}
104+
count := 0
105+
for _, imp := range importers {
106+
if displayedFiles[imp] {
107+
count++
108+
}
109+
}
110+
if count > 0 {
111+
depCounts[file] = count
112+
}
205113
}
114+
} else {
115+
internalDeps = make(map[string][]string)
116+
depCounts = make(map[string]int)
206117
}
207118

208119
// Group by top-level system

scanner/filegraph.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,13 @@ func BuildFileGraph(root string) (*FileGraph, error) {
6565

6666
for _, imp := range a.Imports {
6767
resolved := fuzzyResolve(imp, a.Path, idx, fg.Module)
68-
resolvedImports = append(resolvedImports, resolved...)
68+
// Only count imports that resolve to exactly one file.
69+
// If an import resolves to multiple files, it's a package/module
70+
// import (Go, Python, Rust, etc.) not a file-level import.
71+
// This ensures hub detection works correctly across all languages.
72+
if len(resolved) == 1 {
73+
resolvedImports = append(resolvedImports, resolved[0])
74+
}
6975
}
7076

7177
if len(resolvedImports) > 0 {

0 commit comments

Comments
 (0)