Skip to content

Commit 5708dd5

Browse files
authored
fix: resolve Python dot-notation imports in dependency graph (#8)
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
1 parent fc45d00 commit 5708dd5

File tree

3 files changed

+109
-12
lines changed

3 files changed

+109
-12
lines changed

internal/graph/graph.go

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -284,6 +284,12 @@ func detectModuleWithDepth(filePath string, maxDepth int) string {
284284
// resolveImport attempts to match an import string to a known module name.
285285
// Returns the matched module name, or "" if no match (external dependency).
286286
func resolveImport(imp, fileDir string, knownModules []string) string {
287+
// Normalize Python dot-notation imports to path-style.
288+
// ".models" → "./models", "..utils" → "../utils", "...core" → "../../core"
289+
if normalized, ok := normalizePythonImport(imp); ok {
290+
imp = normalized
291+
}
292+
287293
// Relative imports: ./foo or ../foo
288294
if strings.HasPrefix(imp, "./") || strings.HasPrefix(imp, "../") {
289295
resolved := filepath.Clean(filepath.Join(fileDir, imp))
@@ -304,23 +310,56 @@ func resolveImport(imp, fileDir string, knownModules []string) string {
304310

305311
// Absolute/package-style imports: match suffix against known module names.
306312
// e.g. import "internal/auth" matches module "internal/auth".
313+
// Also convert dotted imports (Python "os.path" → "os/path") for matching.
314+
slashImp := strings.ReplaceAll(imp, ".", "/")
315+
307316
for _, mod := range knownModules {
308-
if mod == imp {
317+
if mod == imp || mod == slashImp {
309318
return mod
310319
}
311320
// Suffix match: "internal/auth" matches module "internal/auth".
312-
if strings.HasSuffix(imp, "/"+mod) || strings.HasSuffix(imp, mod) {
321+
if strings.HasSuffix(imp, "/"+mod) || strings.HasSuffix(slashImp, "/"+mod) {
322+
return mod
323+
}
324+
// Module is a prefix of the import path (package.submodule → package/).
325+
if strings.HasPrefix(slashImp, mod+"/") || strings.HasPrefix(imp, mod+"/") {
313326
return mod
314327
}
315328
// Module is a suffix of the import path (Go-style).
316-
if strings.HasSuffix(imp, mod) || imp == mod {
329+
if strings.HasSuffix(imp, mod) || strings.HasSuffix(slashImp, mod) {
317330
return mod
318331
}
319332
}
320333

321334
return ""
322335
}
323336

337+
// normalizePythonImport converts Python dot-prefix relative imports to path-style.
338+
// ".models" → ("./models", true), "..utils" → ("../utils", true), "os" → ("", false).
339+
func normalizePythonImport(imp string) (string, bool) {
340+
if !strings.HasPrefix(imp, ".") {
341+
return "", false
342+
}
343+
// Count leading dots.
344+
dots := 0
345+
for dots < len(imp) && imp[dots] == '.' {
346+
dots++
347+
}
348+
rest := imp[dots:]
349+
if rest == "" {
350+
// Bare relative like "." or ".." without module name — can't resolve.
351+
return "", false
352+
}
353+
// Convert dots to path: 1 dot → "./", 2 dots → "../", 3 dots → "../../", etc.
354+
prefix := "./"
355+
for i := 1; i < dots; i++ {
356+
prefix = "../" + prefix
357+
}
358+
// Replace remaining dots in module name with slashes (e.g. ".foo.bar" → "./foo/bar").
359+
rest = strings.ReplaceAll(rest, ".", "/")
360+
return prefix + rest, true
361+
}
362+
324363
// appendUnique appends values to s, skipping duplicates.
325364
func appendUnique(s []string, values ...string) []string {
326365
set := make(map[string]struct{}, len(s))

internal/graph/graph_test.go

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,64 @@ func TestIsolatedModules(t *testing.T) {
144144
}
145145
}
146146

147+
// TestPythonRelativeImports verifies that Python dot-notation relative imports
148+
// produce correct dependency edges.
149+
func TestPythonRelativeImports(t *testing.T) {
150+
files := []*parser.FileInfo{
151+
{Path: "myapp/api/views.py", Language: "python", Imports: []string{"flask", ".models", ".config"}, LineCount: 30},
152+
{Path: "myapp/api/models.py", Language: "python", Exports: []string{"User", "Post"}, LineCount: 20},
153+
{Path: "myapp/api/config.py", Language: "python", Exports: []string{"Settings"}, LineCount: 10},
154+
{Path: "myapp/db/client.py", Language: "python", Exports: []string{"get_conn"}, LineCount: 15},
155+
}
156+
157+
g := Build(files, BuildOptions{MaxDepth: 4})
158+
159+
apiMod := g.Module("myapp/api")
160+
if apiMod == nil {
161+
t.Fatal("expected module myapp/api to exist")
162+
}
163+
164+
// ".models" and ".config" are in the same directory so they resolve to same module.
165+
// They should NOT create self-edges. External "flask" should not appear.
166+
if len(apiMod.DependsOn) != 0 {
167+
t.Errorf("expected no external deps (self-refs filtered), got %v", apiMod.DependsOn)
168+
}
169+
}
170+
171+
// TestPythonParentRelativeImport verifies that ".." style Python imports resolve
172+
// to the parent module.
173+
func TestPythonParentRelativeImport(t *testing.T) {
174+
files := []*parser.FileInfo{
175+
{Path: "myapp/api/views.py", Language: "python", Imports: []string{"..db"}, LineCount: 30},
176+
{Path: "myapp/db/client.py", Language: "python", Exports: []string{"get_conn"}, LineCount: 15},
177+
}
178+
179+
g := Build(files, BuildOptions{MaxDepth: 4})
180+
181+
apiMod := g.Module("myapp/api")
182+
if apiMod == nil {
183+
t.Fatal("expected module myapp/api to exist")
184+
}
185+
assertStringSliceContains(t, apiMod.DependsOn, "myapp/db")
186+
}
187+
188+
// TestPythonDottedAbsoluteImport verifies that Python dotted absolute imports
189+
// (e.g. "myapp.db.client") resolve to the correct module.
190+
func TestPythonDottedAbsoluteImport(t *testing.T) {
191+
files := []*parser.FileInfo{
192+
{Path: "myapp/api/views.py", Language: "python", Imports: []string{"myapp.db.client"}, LineCount: 30},
193+
{Path: "myapp/db/client.py", Language: "python", Exports: []string{"get_conn"}, LineCount: 15},
194+
}
195+
196+
g := Build(files, BuildOptions{MaxDepth: 4})
197+
198+
apiMod := g.Module("myapp/api")
199+
if apiMod == nil {
200+
t.Fatal("expected module myapp/api to exist")
201+
}
202+
assertStringSliceContains(t, apiMod.DependsOn, "myapp/db")
203+
}
204+
147205
// moduleNames is a helper to extract names for error messages.
148206
func moduleNames(mods []*Module) []string {
149207
names := make([]string, len(mods))

stacklit.json

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"$schema": "https://stacklit.dev/schema/v1.json",
33
"version": "1",
4-
"generated_at": "2026-04-10T19:17:44Z",
4+
"generated_at": "2026-04-10T19:43:30Z",
55
"stacklit_version": "dev",
66
"merkle_hash": "30be93d7f694cca2cd354dcaaa49aa84329732f577399f2f0fddaffcfab3b5ef",
77
"project": {
@@ -418,9 +418,9 @@
418418
"type Project"
419419
],
420420
"type_defs": {
421-
"Architecture": "Pattern string, Summary string",
421+
"Dependencies": "Edges [][]string, Entrypoints []string, MostDepended []string, Isolated []string",
422+
"GitInfo": "HotFiles []HotFile, Recent []string, Stable []string",
422423
"Hints": "AddFeature string, TestCmd string, EnvVars []string, DoNotTouch []string",
423-
"HotFile": "Path string, Commits90d int",
424424
"Index": "Schema string, Version string, GeneratedAt string, StacklitVersion string, MerkleHash string, Project Project, Tech Tech, Structure Structure, Modules map[string]ModuleInfo, Dependencies Dependenci...",
425425
"LangStats": "Files int, Lines int",
426426
"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 @@
660660
"hot_files": [
661661
{
662662
"path": "stacklit.json",
663-
"commits_90d": 17
663+
"commits_90d": 18
664664
},
665665
{
666666
"path": "stacklit.mmd",
667667
"commits_90d": 14
668668
},
669669
{
670-
"path": "internal/engine/engine.go",
670+
"path": "README.md",
671671
"commits_90d": 13
672672
},
673673
{
674-
"path": "README.md",
675-
"commits_90d": 12
674+
"path": "internal/engine/engine.go",
675+
"commits_90d": 13
676676
},
677677
{
678678
"path": "assets/template.html",
@@ -740,11 +740,11 @@
740740
}
741741
],
742742
"recent": [
743-
"npm/install.js",
743+
"README.md",
744744
"stacklit.json",
745+
"npm/install.js",
745746
".gitignore",
746747
"COMPARISON.md",
747-
"README.md",
748748
"USAGE.md",
749749
"examples/README.md",
750750
"internal/cli/derive.go",

0 commit comments

Comments
 (0)