Skip to content
Merged
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
6 changes: 1 addition & 5 deletions scanner/astgrep.go
Original file line number Diff line number Diff line change
Expand Up @@ -304,11 +304,7 @@ func extractImportPath(text string) string {
text = text[idx+1:]
}

text = strings.TrimSpace(text)
if idx := strings.LastIndex(text, "."); idx > 0 {
return strings.TrimSpace(text[:idx])
}
return text
return strings.TrimSpace(text)
}

// Python: import foo
Expand Down
4 changes: 2 additions & 2 deletions scanner/astgrep_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -166,8 +166,8 @@ namespace TestApp
if !imports["System"] {
t.Errorf("Expected System import, got: %v", analysis.Imports)
}
if !imports["System.Collections"] {
t.Errorf("Expected System.Collections import, got: %v", analysis.Imports)
if !imports["System.Collections.Generic"] {
t.Errorf("Expected System.Collections.Generic import, got: %v", analysis.Imports)
}
}

Expand Down
64 changes: 64 additions & 0 deletions scanner/deps.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,18 @@ func ReadExternalDeps(root string) map[string][]string {
if c, err := os.ReadFile(path); err == nil {
deps["swift"] = append(deps["swift"], parsePackageSwift(string(c))...)
}
case "packages.config":
if c, err := os.ReadFile(path); err == nil {
deps["csharp"] = append(deps["csharp"], parsePackagesConfig(string(c))...)
}
default:
// .csproj files have project-specific names, so they must be matched
// by extension rather than a fixed case above.
if strings.HasSuffix(info.Name(), ".csproj") {
if c, err := os.ReadFile(path); err == nil {
deps["csharp"] = append(deps["csharp"], parseCsproj(string(c))...)
}
}
}
return nil
})
Expand Down Expand Up @@ -158,3 +170,55 @@ func parsePackageSwift(c string) (deps []string) {
}
return
}

// parseCsproj extracts NuGet package names from a .csproj file.
// It handles <PackageReference Include="Name" ... /> elements.
func parseCsproj(c string) (deps []string) {
for _, line := range strings.Split(c, "\n") {
line = strings.TrimSpace(line)
if !strings.Contains(line, "<PackageReference") {
continue
}
// Match Include="PackageName" (double or single quotes)
for _, attr := range []string{`Include="`, `Include='`} {
if start := strings.Index(line, attr); start >= 0 {
rest := line[start+len(attr):]
quote := string(attr[len(attr)-1])
if end := strings.Index(rest, quote); end >= 0 {
name := rest[:end]
if name != "" {
deps = append(deps, name)
}
}
break
}
}
}
return
}

// parsePackagesConfig extracts NuGet package names from a packages.config file.
// It handles <package id="Name" ... /> elements.
func parsePackagesConfig(c string) (deps []string) {
for _, line := range strings.Split(c, "\n") {
line = strings.TrimSpace(line)
if !strings.Contains(line, "<package") {
continue
}
// Match id="PackageName" (double or single quotes)
for _, attr := range []string{`id="`, `id='`} {
if start := strings.Index(line, attr); start >= 0 {
rest := line[start+len(attr):]
quote := string(attr[len(attr)-1])
if end := strings.Index(rest, quote); end >= 0 {
name := rest[:end]
if name != "" {
deps = append(deps, name)
}
}
break
}
}
}
return
}
125 changes: 125 additions & 0 deletions scanner/deps_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -774,3 +774,128 @@ func TestDepsDetectLanguage(t *testing.T) {
})
}
}

func TestParseCsproj(t *testing.T) {
csproj := `<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="Microsoft.Extensions.Logging" Version="8.0.0" />
<PackageReference Include='Serilog' Version='3.0.0' />
</ItemGroup>
</Project>`

deps := parseCsproj(csproj)

expected := []string{"Newtonsoft.Json", "Microsoft.Extensions.Logging", "Serilog"}

if len(deps) != len(expected) {
t.Errorf("Expected %d deps, got %d: %v", len(expected), len(deps), deps)
}

depsMap := make(map[string]bool)
for _, d := range deps {
depsMap[d] = true
}
for _, exp := range expected {
if !depsMap[exp] {
t.Errorf("Expected dep %q not found in %v", exp, deps)
}
}
}

func TestParseCsprojEmpty(t *testing.T) {
csproj := `<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
</PropertyGroup>
</Project>`

deps := parseCsproj(csproj)
if len(deps) != 0 {
t.Errorf("Expected no deps, got %v", deps)
}
}

func TestParsePackagesConfig(t *testing.T) {
config := `<?xml version="1.0" encoding="utf-8"?>
<packages>
<package id="Newtonsoft.Json" version="13.0.3" targetFramework="net48" />
<package id="NUnit" version="3.14.0" targetFramework="net48" />
<package id='log4net' version='2.0.15' />
</packages>`

deps := parsePackagesConfig(config)

expected := []string{"Newtonsoft.Json", "NUnit", "log4net"}

if len(deps) != len(expected) {
t.Errorf("Expected %d deps, got %d: %v", len(expected), len(deps), deps)
}

depsMap := make(map[string]bool)
for _, d := range deps {
depsMap[d] = true
}
for _, exp := range expected {
if !depsMap[exp] {
t.Errorf("Expected dep %q not found in %v", exp, deps)
}
}
}

func TestReadExternalDepsCsharp(t *testing.T) {
tmpDir := t.TempDir()

csproj := `<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="Serilog" Version="3.0.0" />
</ItemGroup>
</Project>`
if err := os.WriteFile(filepath.Join(tmpDir, "MyApp.csproj"), []byte(csproj), 0644); err != nil {
t.Fatal(err)
}
Comment on lines +859 to +861
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

File writes in this test use mode 0644, but other tests in this file use the Go 1.13+ 0o644 literal (e.g., earlier os.WriteFile(..., 0o644)). Consider using 0o644 here as well for consistency.

Copilot uses AI. Check for mistakes.

deps := ReadExternalDeps(tmpDir)

csDeps, ok := deps["csharp"]
if !ok {
t.Fatal("Expected csharp deps")
}
sort.Strings(csDeps)
expected := []string{"Newtonsoft.Json", "Serilog"}
sort.Strings(expected)
if !reflect.DeepEqual(csDeps, expected) {
t.Errorf("Expected csharp deps %v, got %v", expected, csDeps)
}
}

func TestReadExternalDepsPackagesConfig(t *testing.T) {
tmpDir := t.TempDir()

config := `<?xml version="1.0" encoding="utf-8"?>
<packages>
<package id="Newtonsoft.Json" version="13.0.3" targetFramework="net48" />
<package id="NUnit" version="3.14.0" targetFramework="net48" />
</packages>`
if err := os.WriteFile(filepath.Join(tmpDir, "packages.config"), []byte(config), 0644); err != nil {
t.Fatal(err)
}
Comment on lines +885 to +887
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

File writes in this test use mode 0644, while other tests in this file use 0o644. For consistency (and to match existing style in this file), consider switching this to 0o644.

Copilot uses AI. Check for mistakes.

deps := ReadExternalDeps(tmpDir)

csDeps, ok := deps["csharp"]
if !ok {
t.Fatal("Expected csharp deps from packages.config")
}
sort.Strings(csDeps)
expected := []string{"Newtonsoft.Json", "NUnit"}
sort.Strings(expected)
if !reflect.DeepEqual(csDeps, expected) {
t.Errorf("Expected csharp deps %v, got %v", expected, csDeps)
}
}
49 changes: 43 additions & 6 deletions scanner/filegraph.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,12 +72,14 @@ func BuildFileGraph(root string) (*FileGraph, error) {

for _, imp := range a.Imports {
resolved := fuzzyResolve(imp, a.Path, idx, fg.Module, fg.PathAliases, fg.BaseURL)
// Only count imports that resolve to exactly one file.
// If an import resolves to multiple files, it's a package/module
// import (Go, Python, Rust, etc.) not a file-level import.
// This ensures hub detection works correctly across all languages.
if len(resolved) == 1 {
resolvedImports = append(resolvedImports, resolved[0])
// 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...)
Comment on lines +81 to +82
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep ambiguous multi-file imports unresolved

BuildFileGraph now records every non-Go multi-match (resolvedImports = append(resolvedImports, resolved...)), so ambiguous imports become multiple dependencies instead of remaining unresolved. In projects with duplicate module/file names (for example import config with both app/config.py and tests/config.py), this will add false edges and inflate hub/dependency counts even though the runtime import resolves to a single module by search path. The previous len(resolved)==1 guard prevented this class of corruption.

Useful? React with 👍 / 👎.

}
Comment on lines +75 to 83
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When allowing multi-file resolutions, resolvedImports = append(resolvedImports, resolved...) can add the current file itself if the import resolves to its own directory (e.g., a C# file in MyApp/Models importing MyApp.Models). That creates self-dependencies and can skew importer counts/hub detection. Filter out a.Path from resolved before appending (and/or skip self-edges when building Importers).

Copilot uses AI. Check for mistakes.
}

Expand Down Expand Up @@ -185,6 +187,12 @@ func fuzzyResolve(imp, fromFile string, idx *fileIndex, goModule string, pathAli
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 {
return files
}

return nil
}

Expand Down Expand Up @@ -280,6 +288,35 @@ func trySuffixMatch(normalized string, idx *fileIndex) []string {
return nil
}

// tryDirMatch returns all files whose parent directory matches the given path.
// This resolves namespace-level imports (e.g. C# "using Foo.Bar;" -> "Foo/Bar/")
// 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 {
// Try exact match first
if files, ok := idx.byDir[path]; ok {
return files
}
// Normalize path separators for cross-platform compatibility
nativePath := filepath.FromSlash(path)
if nativePath != path {
if files, ok := idx.byDir[nativePath]; ok {
return files
}
}
// Try suffix match: strip leading segments progressively
// This handles namespace prefixes like MyApp.Models -> Models
parts := strings.Split(filepath.ToSlash(path), "/")
for i := 1; i < len(parts); i++ {
suffix := filepath.Join(parts[i:]...)
if files, ok := idx.byDir[suffix]; ok {
Comment on lines +311 to +313
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Constrain directory suffix fallback for namespace matching

tryDirMatch strips leading namespace segments until any trailing directory matches, which can map external namespaces to unrelated local folders (for example using System.Threading.Tasks; will match a local Tasks/ directory). This introduces false internal dependencies whenever a leaf directory name collides with framework/package namespaces. The suffix fallback should be constrained (e.g., only one known root-namespace trim) instead of matching arbitrary tails.

Useful? React with 👍 / 👎.

return files
}
}
return nil
Comment on lines +291 to +317
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

tryDirMatch returns every file in a matching directory via idx.byDir, which currently includes all file types (e.g., .json, .csproj, README). This will cause directory-based namespace imports to create dependencies to non-source files and inflate counts/hubs. Consider filtering directory matches to recognized source extensions (e.g., via IsSourceExt / ResolverExtensions) or building idx.byDir only from source files for this strategy.

Copilot uses AI. Check for mistakes.
}

// detectModule reads go.mod to find the module name
func detectModule(root string) string {
modFile := filepath.Join(root, "go.mod")
Expand Down
91 changes: 91 additions & 0 deletions scanner/filegraph_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -338,3 +338,94 @@ func TestFileGraphHubAndConnectedFiles(t *testing.T) {
}
})
}

func TestTryDirMatch(t *testing.T) {
files := []FileInfo{
{Path: filepath.Join("MyApp", "Models", "User.cs")},
{Path: filepath.Join("MyApp", "Models", "Product.cs")},
{Path: filepath.Join("MyApp", "Services", "UserService.cs")},
}
idx := buildFileIndex(files, "")

t.Run("matches directory with multiple files", func(t *testing.T) {
got := tryDirMatch(filepath.Join("MyApp", "Models"), idx)
sort.Strings(got)
want := []string{
filepath.Join("MyApp", "Models", "Product.cs"),
filepath.Join("MyApp", "Models", "User.cs"),
}
if !reflect.DeepEqual(got, want) {
t.Errorf("tryDirMatch = %v, want %v", got, want)
}
})

t.Run("matches directory with single file", func(t *testing.T) {
got := tryDirMatch(filepath.Join("MyApp", "Services"), idx)
want := []string{filepath.Join("MyApp", "Services", "UserService.cs")}
if !reflect.DeepEqual(got, want) {
t.Errorf("tryDirMatch = %v, want %v", got, want)
}
})

t.Run("suffix match strips namespace prefix", func(t *testing.T) {
// Files are in Models/ but namespace is ProjectName/Models
files2 := []FileInfo{
{Path: filepath.Join("Models", "User.cs")},
{Path: filepath.Join("Models", "Product.cs")},
}
idx2 := buildFileIndex(files2, "")
got := tryDirMatch(filepath.Join("ProjectName", "Models"), idx2)
sort.Strings(got)
want := []string{
filepath.Join("Models", "Product.cs"),
filepath.Join("Models", "User.cs"),
}
if !reflect.DeepEqual(got, want) {
t.Errorf("tryDirMatch suffix = %v, want %v", got, want)
}
})

t.Run("no match for missing directory", func(t *testing.T) {
got := tryDirMatch(filepath.Join("MyApp", "Missing"), idx)
if got != nil {
t.Errorf("expected nil, got %v", got)
}
})
}

func TestFuzzyResolveCsharpNamespace(t *testing.T) {
userCS := filepath.Join("MyApp", "Models", "User.cs")
productCS := filepath.Join("MyApp", "Models", "Product.cs")
serviceCS := filepath.Join("MyApp", "Services", "UserService.cs")

files := []FileInfo{
{Path: userCS},
{Path: productCS},
{Path: serviceCS},
}
idx := buildFileIndex(files, "")

t.Run("namespace with multiple files resolves via directory", func(t *testing.T) {
got := fuzzyResolve("MyApp.Models", "MyApp/Services/UserService.cs", idx, "", nil, "")
sort.Strings(got)
Comment on lines +408 to +410
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These tests pass hard-coded slash-separated paths (e.g. "MyApp/Services/UserService.cs"). Elsewhere in this package tests use filepath.FromSlash / filepath.Join to stay portable on Windows; using those here would keep the test consistent and avoid path-separator edge cases.

Copilot uses AI. Check for mistakes.
want := []string{productCS, userCS}
if !reflect.DeepEqual(got, want) {
t.Errorf("fuzzyResolve(MyApp.Models) = %v, want %v", got, want)
}
})

t.Run("namespace with single file resolves via directory", func(t *testing.T) {
got := fuzzyResolve("MyApp.Services", "MyApp/Program.cs", idx, "", nil, "")
want := []string{serviceCS}
Comment on lines +417 to +419
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These tests pass hard-coded slash-separated paths (e.g. "MyApp/Program.cs"). For cross-platform consistency with the rest of the scanner tests, prefer filepath.FromSlash / filepath.Join for paths in test inputs.

Copilot uses AI. Check for mistakes.
if !reflect.DeepEqual(got, want) {
t.Errorf("fuzzyResolve(MyApp.Services) = %v, want %v", got, want)
}
})

t.Run("external namespace does not resolve", func(t *testing.T) {
got := fuzzyResolve("System.Collections.Generic", "MyApp/Program.cs", idx, "", nil, "")
if got != nil {
Comment on lines +425 to +427
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test also uses a hard-coded slash-separated path ("MyApp/Program.cs"). Use filepath.FromSlash / filepath.Join to keep tests portable and consistent with other path-handling tests in this package.

Copilot uses AI. Check for mistakes.
t.Errorf("expected nil for external namespace, got %v", got)
}
})
}
Loading