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
12 changes: 6 additions & 6 deletions scanner/deps_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -460,7 +460,7 @@ func TestResolvePathAlias(t *testing.T) {

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := resolvePathAlias(tt.imp, pathAliases, ".", idx)
result := resolvePathAlias(tt.imp, pathAliases, ".", idx, "typescript")
if len(result) == 0 {
t.Errorf("Expected to resolve %q, got no results", tt.imp)
return
Expand All @@ -483,13 +483,13 @@ func TestResolvePathAliasNoMatch(t *testing.T) {
}

// Import that doesn't match any alias
result := resolvePathAlias("lodash", pathAliases, ".", idx)
result := resolvePathAlias("lodash", pathAliases, ".", idx, "typescript")
if len(result) != 0 {
t.Errorf("Expected no results for non-alias import, got %v", result)
}

// Import that matches alias but file doesn't exist
result = resolvePathAlias("@modules/nonexistent", pathAliases, ".", idx)
result = resolvePathAlias("@modules/nonexistent", pathAliases, ".", idx, "typescript")
if len(result) != 0 {
t.Errorf("Expected no results for non-existent file, got %v", result)
}
Expand Down Expand Up @@ -571,7 +571,7 @@ func TestDepsResolveRelative(t *testing.T) {

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := resolveRelative(tt.imp, tt.fromDir, idx)
got := resolveRelative(tt.imp, tt.fromDir, idx, "go")
if !reflect.DeepEqual(got, tt.want) {
t.Fatalf("resolveRelative(%q, %q): want %v, got %v", tt.imp, tt.fromDir, tt.want, got)
}
Expand Down Expand Up @@ -633,13 +633,13 @@ func TestDepsFuzzyResolve(t *testing.T) {
want: []string{tsLogin},
},
{
name: "exact import",
name: "bare typescript import remains external",
imp: "src/shared/utils/helpers",
fromFile: filepath.FromSlash("src/app.ts"),
goModule: "example.com/project",
pathAlias: nil,
baseURL: "",
want: []string{tsHelper},
want: nil,
},
{
name: "suffix import",
Expand Down
117 changes: 76 additions & 41 deletions scanner/filegraph.go
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ func BuildFileGraphFromFilteredAnalyses(root string, analyses []FileAnalysis, fi
// 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
isGoPkg := DetectLanguage(a.Path) == "go" && isLocalGoImport(imp, fg.Module) && len(resolved) > 1
if !isGoPkg && len(resolved) > 0 {
resolvedImports = append(resolvedImports, resolved...)
}
Expand Down Expand Up @@ -155,7 +155,7 @@ func buildFileIndex(files []FileInfo, goModule string) *fileIndex {

// Go package index. Import paths always use forward slashes, so the
// key must be slash-normalized or lookups fail on Windows.
if strings.HasSuffix(path, ".go") && goModule != "" {
if strings.HasSuffix(path, ".go") && !strings.HasSuffix(path, "_test.go") && goModule != "" {
pkgPath := goModule
if dir != "" {
pkgPath = goModule + "/" + filepath.ToSlash(dir)
Expand All @@ -167,65 +167,74 @@ func buildFileIndex(files []FileInfo, goModule string) *fileIndex {
return idx
}

// fuzzyResolve converts an import path to actual file paths using universal matching
// No language-specific switch - relies on pattern matching against file index
// fuzzyResolve converts an import path to compatible local file paths.
func fuzzyResolve(imp, fromFile string, idx *fileIndex, goModule string, pathAliases map[string][]string, baseURL string) []string {
sourceLanguage := DetectLanguage(fromFile)
if sourceLanguage == "" {
return nil
}

fromDir := filepath.Dir(fromFile)
if fromDir == "." {
fromDir = ""
}

// Normalize the import path
normalized := normalizeImport(imp)

// Strategy 1: Go package lookup (if it looks like a Go module import)
if goModule != "" && strings.HasPrefix(imp, goModule) {
if files, ok := idx.goPkgs[imp]; ok {
return files
// Strategy 1: Go package lookup, failing closed for non-local imports.
if sourceLanguage == "go" {
if strings.HasPrefix(imp, ".") {
return resolveRelative(imp, fromDir, idx, sourceLanguage)
}
if !isLocalGoImport(imp, goModule) {
return nil
}
return idx.goPkgs[strings.Trim(imp, "\"'`")]
}

// Normalize the import path
normalized := normalizeImport(imp)

// Strategy 2: Relative path resolution (./foo, ../bar)
if strings.HasPrefix(imp, ".") {
return resolveRelative(imp, fromDir, idx)
}

// Go imports are always full package paths, so a non-relative import from
// a .go file that isn't under the module path is the standard library or a
// third-party module. Fuzzy matching those creates false edges — e.g. the
// stdlib "context" import resolving to a local cmd/context.go — which
// inflate hub counts everywhere downstream. Without a go.mod we can't
// resolve Go imports at all, so we return none rather than guess wrong.
if strings.HasSuffix(fromFile, ".go") {
return nil
return resolveRelative(imp, fromDir, idx, sourceLanguage)
}

// Strategy 3: TypeScript/JavaScript path alias resolution (@modules/auth, @shared/utils)
if len(pathAliases) > 0 {
if files := resolvePathAlias(imp, pathAliases, baseURL, idx); len(files) > 0 {
if isJavaScriptLanguage(sourceLanguage) && len(pathAliases) > 0 {
if files := resolvePathAlias(imp, pathAliases, baseURL, idx, sourceLanguage); len(files) > 0 {
return files
}
}
if isJavaScriptLanguage(sourceLanguage) {
return nil
}

// Strategy 4: Exact match (with common extensions)
if files := tryExactMatch(normalized, idx); len(files) > 0 {
if files := tryExactMatch(normalized, idx, sourceLanguage); len(files) > 0 {
return files
}

// Strategy 5: Suffix match (for nested packages like app.core.config -> */app/core/config.py)
if files := trySuffixMatch(normalized, idx); len(files) > 0 {
if files := trySuffixMatch(normalized, idx, sourceLanguage); len(files) > 0 {
return files
}

// Strategy 6: Directory match (for namespace-level imports like C# "using Foo.Bar;"
// where Foo.Bar normalizes to Foo/Bar and maps to the Foo/Bar/ directory).
if files := tryDirMatch(normalized, idx); len(files) > 0 {
if files := tryDirMatch(normalized, idx, sourceLanguage); len(files) > 0 {
return files
}

return nil
}

func isLocalGoImport(imp, module string) bool {
if module == "" {
return false
}
imp = strings.Trim(imp, "\"'`")
return imp == module || strings.HasPrefix(imp, module+"/")
}

// normalizeImport converts various import syntaxes to a path-like format
func normalizeImport(imp string) string {
// Remove quotes
Expand All @@ -249,7 +258,7 @@ func normalizeImport(imp string) string {
}

// resolveRelative handles ./foo and ../bar style imports
func resolveRelative(imp, fromDir string, idx *fileIndex) []string {
func resolveRelative(imp, fromDir string, idx *fileIndex, sourceLanguage string) []string {
// Count parent directory levels
levels := 0
rest := imp
Expand All @@ -274,32 +283,38 @@ func resolveRelative(imp, fromDir string, idx *fileIndex) []string {
candidate = filepath.Join(targetDir, rest)
}

return tryExactMatch(candidate, idx)
return tryExactMatch(candidate, idx, sourceLanguage)
}

// tryExactMatch looks for exact path matches with common extensions.
// Extension list derived from the canonical scanner registry.
func tryExactMatch(path string, idx *fileIndex) []string {
func tryExactMatch(path string, idx *fileIndex, sourceLanguage string) []string {
extensions := ResolverExtensions()

for _, ext := range extensions {
candidate := path + ext
if files, ok := idx.byExact[candidate]; ok {
return files
if compatible := compatibleFiles(sourceLanguage, files); len(compatible) > 0 {
return compatible
}
}
}

return nil
}

// trySuffixMatch finds files where the path ends with the normalized import
func trySuffixMatch(normalized string, idx *fileIndex) []string {
func trySuffixMatch(normalized string, idx *fileIndex, sourceLanguage string) []string {
// Extension list derived from the canonical scanner registry.
extensions := ResolverExtensions()

for _, ext := range extensions {
candidate := normalized + ext
if files, ok := idx.bySuffix[candidate]; ok {
files = compatibleFiles(sourceLanguage, files)
if len(files) == 0 {
continue
}
// Return the shortest match (most specific)
if len(files) == 1 {
return files
Expand All @@ -312,7 +327,7 @@ func trySuffixMatch(normalized string, idx *fileIndex) []string {
// Also try __init__.py for Python packages
initCandidate := filepath.Join(normalized, "__init__.py")
if files, ok := idx.bySuffix[initCandidate]; ok {
return files
return compatibleFiles(sourceLanguage, files)
}

return nil
Expand All @@ -323,16 +338,20 @@ func trySuffixMatch(normalized string, idx *fileIndex) []string {
// where an import refers to a whole directory rather than a single file.
// It also tries progressively shorter suffixes to handle namespace prefixes
// (e.g. "MyApp/Models" tries "MyApp/Models" first, then "Models").
func tryDirMatch(path string, idx *fileIndex) []string {
func tryDirMatch(path string, idx *fileIndex, sourceLanguage string) []string {
// Try exact match first
if files, ok := idx.byDir[path]; ok {
return files
if compatible := compatibleFiles(sourceLanguage, files); len(compatible) > 0 {
return compatible
}
}
// Normalize path separators for cross-platform compatibility
nativePath := filepath.FromSlash(path)
if nativePath != path {
if files, ok := idx.byDir[nativePath]; ok {
return files
if compatible := compatibleFiles(sourceLanguage, files); len(compatible) > 0 {
return compatible
}
}
}
// Try suffix match: strip leading segments progressively
Expand All @@ -341,12 +360,28 @@ func tryDirMatch(path string, idx *fileIndex) []string {
for i := 1; i < len(parts); i++ {
suffix := filepath.Join(parts[i:]...)
if files, ok := idx.byDir[suffix]; ok {
return files
if compatible := compatibleFiles(sourceLanguage, files); len(compatible) > 0 {
return compatible
}
}
}
return nil
}

func compatibleFiles(sourceLanguage string, files []string) []string {
var compatible []string
for _, file := range files {
if languagesCompatible(sourceLanguage, DetectLanguage(file)) {
compatible = append(compatible, file)
}
}
return compatible
}

func isJavaScriptLanguage(language string) bool {
return language == "javascript" || language == "typescript"
}

// detectModule reads go.mod to find the module name
func detectModule(root string) string {
modFile := filepath.Join(root, "go.mod")
Expand Down Expand Up @@ -512,7 +547,7 @@ func readTSConfig(configPath, root string) (map[string][]string, string) {

// resolvePathAlias attempts to resolve an import using TypeScript path aliases
// e.g., "@modules/auth" with alias "@modules/*" -> ["src/modules/*"] becomes "src/modules/auth"
func resolvePathAlias(imp string, pathAliases map[string][]string, baseURL string, idx *fileIndex) []string {
func resolvePathAlias(imp string, pathAliases map[string][]string, baseURL string, idx *fileIndex, sourceLanguage string) []string {
// Try each alias pattern
for pattern, targets := range pathAliases {
var prefix, suffix string
Expand All @@ -527,7 +562,7 @@ func resolvePathAlias(imp string, pathAliases map[string][]string, baseURL strin
if baseURL != "" && !filepath.IsAbs(resolved) {
resolved = filepath.Join(baseURL, resolved)
}
if files := tryExactMatch(resolved, idx); len(files) > 0 {
if files := tryExactMatch(resolved, idx, sourceLanguage); len(files) > 0 {
return files
}
}
Expand Down Expand Up @@ -563,10 +598,10 @@ func resolvePathAlias(imp string, pathAliases map[string][]string, baseURL strin
}

// Try to find matching files
if files := tryExactMatch(resolved, idx); len(files) > 0 {
if files := tryExactMatch(resolved, idx, sourceLanguage); len(files) > 0 {
return files
}
if files := trySuffixMatch(resolved, idx); len(files) > 0 {
if files := trySuffixMatch(resolved, idx, sourceLanguage); len(files) > 0 {
return files
}
}
Expand Down
Loading
Loading