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
45 changes: 42 additions & 3 deletions internal/graph/graph.go
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,12 @@ func detectModuleWithDepth(filePath string, maxDepth int) string {
// resolveImport attempts to match an import string to a known module name.
// Returns the matched module name, or "" if no match (external dependency).
func resolveImport(imp, fileDir string, knownModules []string) string {
// Normalize Python dot-notation imports to path-style.
// ".models" → "./models", "..utils" → "../utils", "...core" → "../../core"
if normalized, ok := normalizePythonImport(imp); ok {
imp = normalized
}

// Relative imports: ./foo or ../foo
if strings.HasPrefix(imp, "./") || strings.HasPrefix(imp, "../") {
resolved := filepath.Clean(filepath.Join(fileDir, imp))
Expand All @@ -304,23 +310,56 @@ func resolveImport(imp, fileDir string, knownModules []string) string {

// Absolute/package-style imports: match suffix against known module names.
// e.g. import "internal/auth" matches module "internal/auth".
// Also convert dotted imports (Python "os.path" → "os/path") for matching.
slashImp := strings.ReplaceAll(imp, ".", "/")
Comment on lines +313 to +314

for _, mod := range knownModules {
if mod == imp {
if mod == imp || mod == slashImp {
return mod
}
// Suffix match: "internal/auth" matches module "internal/auth".
if strings.HasSuffix(imp, "/"+mod) || strings.HasSuffix(imp, mod) {
if strings.HasSuffix(imp, "/"+mod) || strings.HasSuffix(slashImp, "/"+mod) {
return mod
}
// Module is a prefix of the import path (package.submodule → package/).
if strings.HasPrefix(slashImp, mod+"/") || strings.HasPrefix(imp, mod+"/") {
return mod
}
// Module is a suffix of the import path (Go-style).
if strings.HasSuffix(imp, mod) || imp == mod {
if strings.HasSuffix(imp, mod) || strings.HasSuffix(slashImp, mod) {
return mod
Comment on lines 316 to 330
}
}

return ""
}

// normalizePythonImport converts Python dot-prefix relative imports to path-style.
// ".models" → ("./models", true), "..utils" → ("../utils", true), "os" → ("", false).
func normalizePythonImport(imp string) (string, bool) {
if !strings.HasPrefix(imp, ".") {
return "", false
}
// Count leading dots.
dots := 0
for dots < len(imp) && imp[dots] == '.' {
dots++
}
rest := imp[dots:]
if rest == "" {
// Bare relative like "." or ".." without module name — can't resolve.
return "", false
}
// Convert dots to path: 1 dot → "./", 2 dots → "../", 3 dots → "../../", etc.
prefix := "./"
for i := 1; i < dots; i++ {
prefix = "../" + prefix
}
// Replace remaining dots in module name with slashes (e.g. ".foo.bar" → "./foo/bar").
rest = strings.ReplaceAll(rest, ".", "/")
return prefix + rest, true
}

// appendUnique appends values to s, skipping duplicates.
func appendUnique(s []string, values ...string) []string {
set := make(map[string]struct{}, len(s))
Expand Down
58 changes: 58 additions & 0 deletions internal/graph/graph_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,64 @@ func TestIsolatedModules(t *testing.T) {
}
}

// TestPythonRelativeImports verifies that Python dot-notation relative imports
// produce correct dependency edges.
func TestPythonRelativeImports(t *testing.T) {
files := []*parser.FileInfo{
{Path: "myapp/api/views.py", Language: "python", Imports: []string{"flask", ".models", ".config"}, LineCount: 30},
{Path: "myapp/api/models.py", Language: "python", Exports: []string{"User", "Post"}, LineCount: 20},
{Path: "myapp/api/config.py", Language: "python", Exports: []string{"Settings"}, LineCount: 10},
{Path: "myapp/db/client.py", Language: "python", Exports: []string{"get_conn"}, LineCount: 15},
}

g := Build(files, BuildOptions{MaxDepth: 4})

apiMod := g.Module("myapp/api")
if apiMod == nil {
t.Fatal("expected module myapp/api to exist")
}

// ".models" and ".config" are in the same directory so they resolve to same module.
// They should NOT create self-edges. External "flask" should not appear.
if len(apiMod.DependsOn) != 0 {
t.Errorf("expected no external deps (self-refs filtered), got %v", apiMod.DependsOn)
}
Comment on lines +147 to +168
}

// TestPythonParentRelativeImport verifies that ".." style Python imports resolve
// to the parent module.
func TestPythonParentRelativeImport(t *testing.T) {
files := []*parser.FileInfo{
{Path: "myapp/api/views.py", Language: "python", Imports: []string{"..db"}, LineCount: 30},
{Path: "myapp/db/client.py", Language: "python", Exports: []string{"get_conn"}, LineCount: 15},
}

g := Build(files, BuildOptions{MaxDepth: 4})

apiMod := g.Module("myapp/api")
if apiMod == nil {
t.Fatal("expected module myapp/api to exist")
}
assertStringSliceContains(t, apiMod.DependsOn, "myapp/db")
}

// TestPythonDottedAbsoluteImport verifies that Python dotted absolute imports
// (e.g. "myapp.db.client") resolve to the correct module.
func TestPythonDottedAbsoluteImport(t *testing.T) {
files := []*parser.FileInfo{
{Path: "myapp/api/views.py", Language: "python", Imports: []string{"myapp.db.client"}, LineCount: 30},
{Path: "myapp/db/client.py", Language: "python", Exports: []string{"get_conn"}, LineCount: 15},
}

g := Build(files, BuildOptions{MaxDepth: 4})

apiMod := g.Module("myapp/api")
if apiMod == nil {
t.Fatal("expected module myapp/api to exist")
}
assertStringSliceContains(t, apiMod.DependsOn, "myapp/db")
}

// moduleNames is a helper to extract names for error messages.
func moduleNames(mods []*Module) []string {
names := make([]string, len(mods))
Expand Down
18 changes: 9 additions & 9 deletions stacklit.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"$schema": "https://stacklit.dev/schema/v1.json",
"version": "1",
"generated_at": "2026-04-10T19:17:44Z",
"generated_at": "2026-04-10T19:43:30Z",
"stacklit_version": "dev",
"merkle_hash": "30be93d7f694cca2cd354dcaaa49aa84329732f577399f2f0fddaffcfab3b5ef",
"project": {
Expand Down Expand Up @@ -418,9 +418,9 @@
"type Project"
],
"type_defs": {
"Architecture": "Pattern string, Summary string",
"Dependencies": "Edges [][]string, Entrypoints []string, MostDepended []string, Isolated []string",
"GitInfo": "HotFiles []HotFile, Recent []string, Stable []string",
"Hints": "AddFeature string, TestCmd string, EnvVars []string, DoNotTouch []string",
"HotFile": "Path string, Commits90d int",
"Index": "Schema string, Version string, GeneratedAt string, StacklitVersion string, MerkleHash string, Project Project, Tech Tech, Structure Structure, Modules map[string]ModuleInfo, Dependencies Dependenci...",
"LangStats": "Files int, Lines int",
"ModuleInfo": "Purpose string, Language string, Files int, Lines int, FileList []string, Exports []string, TypeDefs map[string]string, DependsOn []string, DependedBy []string, Activity string",
Expand Down Expand Up @@ -660,19 +660,19 @@
"hot_files": [
{
"path": "stacklit.json",
"commits_90d": 17
"commits_90d": 18
},
{
"path": "stacklit.mmd",
"commits_90d": 14
},
{
"path": "internal/engine/engine.go",
"path": "README.md",
"commits_90d": 13
},
{
"path": "README.md",
"commits_90d": 12
"path": "internal/engine/engine.go",
"commits_90d": 13
},
{
"path": "assets/template.html",
Expand Down Expand Up @@ -740,11 +740,11 @@
}
],
"recent": [
"npm/install.js",
"README.md",
"stacklit.json",
"npm/install.js",
".gitignore",
"COMPARISON.md",
"README.md",
"USAGE.md",
"examples/README.md",
"internal/cli/derive.go",
Expand Down
Loading