diff --git a/scanner/deps_test.go b/scanner/deps_test.go index acc4164..3ef1cf1 100644 --- a/scanner/deps_test.go +++ b/scanner/deps_test.go @@ -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 @@ -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) } @@ -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) } @@ -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", diff --git a/scanner/filegraph.go b/scanner/filegraph.go index 01439ed..154fcbe 100644 --- a/scanner/filegraph.go +++ b/scanner/filegraph.go @@ -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...) } @@ -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) @@ -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 @@ -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 @@ -274,18 +283,20 @@ 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 + } } } @@ -293,13 +304,17 @@ func tryExactMatch(path string, idx *fileIndex) []string { } // 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 @@ -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 @@ -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 @@ -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") @@ -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 @@ -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 } } @@ -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 } } diff --git a/scanner/filegraph_test.go b/scanner/filegraph_test.go index b05393f..9c7ffe1 100644 --- a/scanner/filegraph_test.go +++ b/scanner/filegraph_test.go @@ -133,7 +133,7 @@ func TestResolveRelative(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.expected) { t.Errorf("resolveRelative(%q, %q) = %v, want %v", tt.imp, tt.fromDir, got, tt.expected) } @@ -151,28 +151,32 @@ func TestTrySuffixMatch(t *testing.T) { tests := []struct { name string normalized string + language string expected []string }{ { name: "python package init fallback", normalized: filepath.Join("pkg"), + language: "python", expected: []string{filepath.Join("src", "pkg", "__init__.py")}, }, { name: "direct suffix with extension", normalized: filepath.Join("auth", "service"), + language: "typescript", expected: []string{filepath.Join("src", "auth", "service.ts")}, }, { name: "no match", normalized: "missing/path", + language: "typescript", expected: nil, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := trySuffixMatch(tt.normalized, idx) + got := trySuffixMatch(tt.normalized, idx, tt.language) if !reflect.DeepEqual(got, tt.expected) { t.Errorf("trySuffixMatch(%q) = %v, want %v", tt.normalized, got, tt.expected) } @@ -221,7 +225,7 @@ func TestFuzzyResolve(t *testing.T) { { name: "path alias strategy", imp: "@modules/auth/login", - fromFile: filepath.Join("src", "shared", "config.py"), + fromFile: filepath.Join("src", "app.ts"), aliases: pathAliases, baseURL: ".", expected: []string{filepath.Join("src", "modules", "auth", "login.ts")}, @@ -229,7 +233,7 @@ func TestFuzzyResolve(t *testing.T) { { name: "normalized dotted exact strategy", imp: "src.shared.config", - fromFile: filepath.Join("src", "modules", "auth", "login.ts"), + fromFile: filepath.Join("src", "app.py"), aliases: nil, baseURL: "", expected: []string{filepath.Join("src", "shared", "config.py")}, @@ -262,6 +266,367 @@ func TestFuzzyResolve(t *testing.T) { } } +func TestFuzzyResolveGoImportsFailClosed(t *testing.T) { + module := "example.com/project" + onlyGo := filepath.Join("pkg", "only", "only.go") + files := []FileInfo{ + {Path: "main.go"}, + {Path: filepath.Join("cmd", "context.go")}, + {Path: filepath.Join("mcp", "main.go")}, + {Path: filepath.Join("foo", "main.go")}, + {Path: onlyGo}, + {Path: filepath.Join("pkg", "only", "only_test.go")}, + {Path: filepath.Join("fixtures", "example.com", "project", "missing.go")}, + {Path: filepath.Join("src", "shared", "config.py")}, + } + idx := buildFileIndex(files, module) + + tests := []struct { + name string + imp string + module string + want []string + }{ + { + name: "exact module root resolves", + imp: module, + module: module, + want: []string{"main.go"}, + }, + { + name: "local package ignores test files", + imp: module + "/pkg/only", + module: module, + want: []string{onlyGo}, + }, + { + name: "standard library import cannot match a local filename", + imp: "context", + module: module, + }, + { + name: "external package cannot match a local directory", + imp: "github.com/modelcontextprotocol/go-sdk/mcp", + module: module, + }, + { + name: "module prefix sibling is not local", + imp: "example.com/project-tools/foo", + module: module, + }, + { + name: "unknown local package cannot fall through to fuzzy matching", + imp: module + "/missing", + module: module, + }, + { + name: "missing module fails closed", + imp: "context", + }, + { + name: "relative import keeps existing resolution", + imp: "../foo/main", + want: []string{filepath.Join("foo", "main.go")}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := fuzzyResolve(tt.imp, filepath.Join("cmd", "caller.go"), idx, tt.module, nil, "") + if !reflect.DeepEqual(got, tt.want) { + t.Fatalf("fuzzyResolve(%q) = %v, want %v", tt.imp, got, tt.want) + } + }) + } +} + +func TestBuildFileGraphGoPackageTargets(t *testing.T) { + root := t.TempDir() + module := "example.com/project" + paths := []string{ + "go.mod", + "cmd/caller.go", + "cmd/caller_test.go", + "cmd/context.go", + "mcp/main.go", + "fixtures/example.com/project/missing.go", + "pkg/only/only.go", + "pkg/only/only_test.go", + "pkg/testonly/only_test.go", + "pkg/multi/a.go", + "pkg/multi/b.go", + } + for _, path := range paths { + fullPath := filepath.Join(root, filepath.FromSlash(path)) + if err := os.MkdirAll(filepath.Dir(fullPath), 0o755); err != nil { + t.Fatal(err) + } + content := []byte(nil) + if path == "go.mod" { + content = []byte("module " + module + "\n\ngo 1.24.0\n") + } + if err := os.WriteFile(fullPath, content, 0o644); err != nil { + t.Fatal(err) + } + } + + imports := []string{ + "context", + "github.com/modelcontextprotocol/go-sdk/mcp", + "example.com/project-tools/pkg/only", + module + "/missing", + module + "/pkg/only", + module + "/pkg/testonly", + module + "/pkg/multi", + } + analyses := []FileAnalysis{ + {Path: filepath.FromSlash("cmd/caller.go"), Language: "go", Imports: imports}, + {Path: filepath.FromSlash("cmd/caller_test.go"), Language: "go", Imports: imports}, + } + + graph, err := BuildFileGraphFromAnalyses(root, analyses) + if err != nil { + t.Fatal(err) + } + + want := []string{filepath.FromSlash("pkg/only/only.go")} + for _, caller := range []string{"cmd/caller.go", "cmd/caller_test.go"} { + caller = filepath.FromSlash(caller) + if got := graph.Imports[caller]; !reflect.DeepEqual(got, want) { + t.Errorf("imports[%q] = %v, want %v", caller, got, want) + } + } + if got := graph.Importers[want[0]]; !reflect.DeepEqual(got, []string{ + filepath.FromSlash("cmd/caller.go"), + filepath.FromSlash("cmd/caller_test.go"), + }) { + t.Errorf("importers[%q] = %v", want[0], got) + } +} + +func TestLanguageCompatibility(t *testing.T) { + tests := []struct { + name string + importer string + candidate string + want bool + }{ + {name: "javascript to typescript", importer: "javascript", candidate: "typescript", want: true}, + {name: "typescript to javascript", importer: "typescript", candidate: "javascript", want: true}, + {name: "c to cpp", importer: "c", candidate: "cpp", want: true}, + {name: "cpp to c", importer: "cpp", candidate: "c", want: true}, + {name: "java to kotlin", importer: "java", candidate: "kotlin", want: true}, + {name: "kotlin to scala", importer: "kotlin", candidate: "scala", want: true}, + {name: "scala to java", importer: "scala", candidate: "java", want: true}, + {name: "python singleton", importer: "python", candidate: "python", want: true}, + {name: "csharp singleton", importer: "csharp", candidate: "csharp", want: true}, + {name: "go singleton", importer: "go", candidate: "go", want: true}, + {name: "rust singleton", importer: "rust", candidate: "rust", want: true}, + {name: "swift singleton", importer: "swift", candidate: "swift", want: true}, + {name: "ruby singleton", importer: "ruby", candidate: "ruby", want: true}, + {name: "php singleton", importer: "php", candidate: "php", want: true}, + {name: "lua singleton", importer: "lua", candidate: "lua", want: true}, + {name: "elixir singleton", importer: "elixir", candidate: "elixir", want: true}, + {name: "solidity singleton", importer: "solidity", candidate: "solidity", want: true}, + {name: "bash singleton", importer: "bash", candidate: "bash", want: true}, + {name: "cross family rejected", importer: "typescript", candidate: "rust", want: false}, + {name: "unknown importer rejected", importer: "", candidate: "typescript", want: false}, + {name: "unknown candidate rejected", importer: "typescript", candidate: "", want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := languagesCompatible(tt.importer, tt.candidate); got != tt.want { + t.Fatalf("languagesCompatible(%q, %q) = %v, want %v", tt.importer, tt.candidate, got, tt.want) + } + }) + } +} + +func TestBuildFileGraphImportResolutionBoundaries(t *testing.T) { + root := t.TempDir() + paths := []string{ + "crates/path.rs", + "crates/mod.rs", + "web/path.ts", + "web/url.ts", + "web/target.scala", + "web/target.ts", + "web/icon.js", + "web/app.ts", + "web/relative.ts", + "native/header.hpp", + "native/main.c", + "jvm/shared/Model.kt", + "jvm/App.java", + "python/services/__init__.py", + "python/services/index.ts", + "python/main.py", + "MyApp/Models/User.cs", + "MyApp/Models/helper.py", + "MyApp/Program.cs", + "pkg/single/util.go", + "pkg/multi/a.go", + "pkg/multi/b.go", + "cmd/main.go", + "cmd/multi.go", + } + for _, path := range paths { + fullPath := filepath.Join(root, filepath.FromSlash(path)) + if err := os.MkdirAll(filepath.Dir(fullPath), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(fullPath, nil, 0o644); err != nil { + t.Fatal(err) + } + } + if err := os.WriteFile(filepath.Join(root, "go.mod"), []byte("module example.com/project\n\ngo 1.24.0\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "tsconfig.json"), []byte(`{ + "compilerOptions": { + "baseUrl": ".", + "paths": {"@local/*": ["web/*"]} + } +}`), 0o644); err != nil { + t.Fatal(err) + } + + analyses := []FileAnalysis{ + {Path: "crates/mod.rs", Language: "rust", Imports: []string{"crate::path"}}, + {Path: "web/icon.js", Language: "javascript", Imports: []string{"path", "url", "node:path"}}, + {Path: "web/app.ts", Language: "typescript", Imports: []string{"path", "url", "node:path"}}, + {Path: "web/relative.ts", Language: "typescript", Imports: []string{"./path", "./target", "@local/path"}}, + {Path: "native/main.c", Language: "c", Imports: []string{"./header"}}, + {Path: "jvm/App.java", Language: "java", Imports: []string{"jvm.shared.Model"}}, + {Path: "python/main.py", Language: "python", Imports: []string{"python.services"}}, + {Path: "MyApp/Program.cs", Language: "csharp", Imports: []string{"MyApp.Models"}}, + {Path: "cmd/main.go", Language: "go", Imports: []string{"example.com/project/pkg/single"}}, + {Path: "cmd/multi.go", Language: "go", Imports: []string{"example.com/project/pkg/multi"}}, + {Path: "README.md", Language: "", Imports: []string{"path"}}, + } + + fg, err := BuildFileGraphFromAnalyses(root, analyses) + if err != nil { + t.Fatal(err) + } + + assertImporters := func(path string, want []string) { + t.Helper() + got := append([]string(nil), fg.Importers[filepath.FromSlash(path)]...) + sort.Strings(got) + sort.Strings(want) + if !reflect.DeepEqual(got, want) { + t.Fatalf("importers[%q] = %v, want %v", path, got, want) + } + } + assertImports := func(path string, want []string) { + t.Helper() + got := append([]string(nil), fg.Imports[filepath.FromSlash(path)]...) + sort.Strings(got) + sort.Strings(want) + if !reflect.DeepEqual(got, want) { + t.Fatalf("imports[%q] = %v, want %v", path, got, want) + } + } + + assertImporters("crates/path.rs", []string{"crates/mod.rs"}) + assertImporters("web/url.ts", nil) + assertImports("web/relative.ts", []string{"web/path.ts", "web/target.ts"}) + assertImports("native/main.c", []string{"native/header.hpp"}) + assertImports("jvm/App.java", []string{"jvm/shared/Model.kt"}) + assertImports("python/main.py", []string{"python/services/__init__.py"}) + assertImports("MyApp/Program.cs", []string{"MyApp/Models/User.cs"}) + assertImports("cmd/main.go", []string{"pkg/single/util.go"}) + assertImports("cmd/multi.go", nil) + assertImports("README.md", nil) +} + +func TestFilteredImportResolutionTargets(t *testing.T) { + root := t.TempDir() + module := "example.com/filtered" + files := map[string]string{ + "go.mod": "module " + module + "\n\ngo 1.26\n", + "tsconfig.json": `{"compilerOptions":{"baseUrl":".","paths":{"@blocked/*":["blocked/*"]}}}`, + "src/app.ts": `import { blocked } from "@blocked/target"` + "\n", + "blocked/target.ts": "export const blocked = true\n", + "cmd/caller.go": "package cmd\n\nimport _ \"" + module + "/pkg/filteredpkg\"\n", + "pkg/filteredpkg/target.go": "package target\n", + "pkg/filteredpkg/target_test.go": "package target\n", + } + for path, content := range files { + fullPath := filepath.Join(root, filepath.FromSlash(path)) + if err := os.MkdirAll(filepath.Dir(fullPath), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(fullPath, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + } + + filters := Filters{Exclude: []string{ + filepath.FromSlash("blocked/target.ts"), + filepath.FromSlash("pkg/filteredpkg/target.go"), + }} + scanned, err := ScanFiles(root, NewGitIgnoreCache(root), filters.Only, filters.Exclude) + if err != nil { + t.Fatalf("ScanFiles() error: %v", err) + } + retainedTestTarget := false + for _, file := range scanned { + if file.Path == filepath.FromSlash("pkg/filteredpkg/target_test.go") { + retainedTestTarget = true + break + } + } + if !retainedTestTarget { + t.Fatalf("fixture did not retain the _test.go-only Go package target; scanned = %#v", scanned) + } + analyses := []FileAnalysis{ + {Path: filepath.FromSlash("src/app.ts"), Language: "typescript", Imports: []string{"@blocked/target"}}, + {Path: filepath.FromSlash("cmd/caller.go"), Language: "go", Imports: []string{module + "/pkg/filteredpkg"}}, + } + + assertFilteredTargets := func(t *testing.T, graph *FileGraph) { + t.Helper() + if got := graph.Imports[filepath.FromSlash("src/app.ts")]; len(got) != 0 { + t.Errorf("filtered alias target resolved: %v", got) + } + if got := graph.Imports[filepath.FromSlash("cmd/caller.go")]; len(got) != 0 { + t.Errorf("filtered Go package target resolved: %v", got) + } + if got := graph.Packages[module+"/pkg/filteredpkg"]; len(got) != 0 { + t.Errorf("filtered Go package remained indexed: %v", got) + } + } + + precomputed, err := BuildFileGraphFromFilteredAnalyses(root, analyses, filters) + if err != nil { + t.Fatalf("BuildFileGraphFromFilteredAnalyses() error: %v", err) + } + assertFilteredTargets(t, precomputed) + + t.Run("live graph matches precomputed graph", func(t *testing.T) { + if !NewAstGrepAnalyzer().Available() { + t.Skip("ast-grep not available") + } + live, err := BuildFileGraphWithFilters(root, filters) + if err != nil { + t.Fatalf("BuildFileGraphWithFilters() error: %v", err) + } + assertFilteredTargets(t, live) + if !reflect.DeepEqual(live.Imports, precomputed.Imports) { + t.Errorf("live imports = %#v, precomputed imports = %#v", live.Imports, precomputed.Imports) + } + if !reflect.DeepEqual(live.Importers, precomputed.Importers) { + t.Errorf("live importers = %#v, precomputed importers = %#v", live.Importers, precomputed.Importers) + } + if !reflect.DeepEqual(live.Packages, precomputed.Packages) { + t.Errorf("live packages = %#v, precomputed packages = %#v", live.Packages, precomputed.Packages) + } + }) +} + func TestDetectModule(t *testing.T) { tests := []struct { name string @@ -348,7 +713,7 @@ func TestTryDirMatch(t *testing.T) { idx := buildFileIndex(files, "") t.Run("matches directory with multiple files", func(t *testing.T) { - got := tryDirMatch(filepath.Join("MyApp", "Models"), idx) + got := tryDirMatch(filepath.Join("MyApp", "Models"), idx, "csharp") sort.Strings(got) want := []string{ filepath.Join("MyApp", "Models", "Product.cs"), @@ -360,7 +725,7 @@ func TestTryDirMatch(t *testing.T) { }) t.Run("matches directory with single file", func(t *testing.T) { - got := tryDirMatch(filepath.Join("MyApp", "Services"), idx) + got := tryDirMatch(filepath.Join("MyApp", "Services"), idx, "csharp") want := []string{filepath.Join("MyApp", "Services", "UserService.cs")} if !reflect.DeepEqual(got, want) { t.Errorf("tryDirMatch = %v, want %v", got, want) @@ -374,7 +739,7 @@ func TestTryDirMatch(t *testing.T) { {Path: filepath.Join("Models", "Product.cs")}, } idx2 := buildFileIndex(files2, "") - got := tryDirMatch(filepath.Join("ProjectName", "Models"), idx2) + got := tryDirMatch(filepath.Join("ProjectName", "Models"), idx2, "csharp") sort.Strings(got) want := []string{ filepath.Join("Models", "Product.cs"), @@ -386,7 +751,7 @@ func TestTryDirMatch(t *testing.T) { }) t.Run("no match for missing directory", func(t *testing.T) { - got := tryDirMatch(filepath.Join("MyApp", "Missing"), idx) + got := tryDirMatch(filepath.Join("MyApp", "Missing"), idx, "csharp") if got != nil { t.Errorf("expected nil, got %v", got) } diff --git a/scanner/types.go b/scanner/types.go index bf94346..143d694 100644 --- a/scanner/types.go +++ b/scanner/types.go @@ -155,6 +155,35 @@ func ResolverExtensions() []string { return exts } +var resolverLanguageFamilies = map[string]string{ + "javascript": "js", + "typescript": "js", + "c": "c-cpp", + "cpp": "c-cpp", + "java": "jvm", + "kotlin": "jvm", + "scala": "jvm", + "python": "python", + "csharp": "csharp", + "go": "go", + "rust": "rust", + "swift": "swift", + "ruby": "ruby", + "php": "php", + "lua": "lua", + "elixir": "elixir", + "solidity": "solidity", + "bash": "bash", +} + +// languagesCompatible reports whether an import may resolve across the two +// source languages. Unknown languages are deliberately incompatible. +func languagesCompatible(importer, candidate string) bool { + importerFamily, importerKnown := resolverLanguageFamilies[importer] + candidateFamily, candidateKnown := resolverLanguageFamilies[candidate] + return importerKnown && candidateKnown && importerFamily == candidateFamily +} + // LangDisplay maps internal language names to display names var LangDisplay = map[string]string{ "go": "Go",