From 54c993121a9dd72c8a72c3d1192a555e49e4c78c Mon Sep 17 00:00:00 2001 From: GDS K S Date: Sat, 11 Apr 2026 15:34:42 -0500 Subject: [PATCH] fix: resolve Python dot-notation imports in dependency graph Python relative imports (.models, ..utils) and dotted absolute imports (myapp.db.client) were silently dropped because resolveImport only handled path-style separators (./foo, ../foo). Adds normalizePythonImport to convert dot-prefix to path-style, and slash-converts dotted package imports for module matching. Fixes #7 --- internal/graph/graph.go | 45 ++++++++++++++++++++++++++-- internal/graph/graph_test.go | 58 ++++++++++++++++++++++++++++++++++++ stacklit.json | 18 +++++------ 3 files changed, 109 insertions(+), 12 deletions(-) diff --git a/internal/graph/graph.go b/internal/graph/graph.go index 27988c9..779160f 100644 --- a/internal/graph/graph.go +++ b/internal/graph/graph.go @@ -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)) @@ -304,16 +310,23 @@ 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, ".", "/") + 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 } } @@ -321,6 +334,32 @@ func resolveImport(imp, fileDir string, knownModules []string) string { 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)) diff --git a/internal/graph/graph_test.go b/internal/graph/graph_test.go index 957e7fa..5bee5a3 100644 --- a/internal/graph/graph_test.go +++ b/internal/graph/graph_test.go @@ -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) + } +} + +// 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)) diff --git a/stacklit.json b/stacklit.json index 1004894..d00c2d1 100644 --- a/stacklit.json +++ b/stacklit.json @@ -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": { @@ -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", @@ -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", @@ -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",