1+ /**
2+ * Python import resolution for static analysis.
3+ *
4+ * Resolves Python import statements to file paths without executing code.
5+ * Handles relative imports, absolute imports, namespace packages (PEP 420),
6+ * and re-exports from __init__.py files.
7+ *
8+ * Resolution order (matching Python):
9+ * 1. module.py (direct file)
10+ * 2. module/__init__.py (package)
11+ * 3. module/ without __init__.py (namespace package)
12+ */
13+
114import { existsSync } from "node:fs"
2- import { dirname } from "node:path"
15+ import { dirname , join } from "node:path"
316import { analyzeFile } from "./analyzer"
417import type { ImportInfo } from "./internal"
518import type { Parser } from "./parser"
619
20+ /**
21+ * Cache for file existence checks to avoid repeated filesystem calls.
22+ * Maps file path -> exists (true/false).
23+ */
24+ const fileExistsCache = new Map < string , boolean > ( )
25+
26+ function cachedExistsSync ( path : string ) : boolean {
27+ const cached = fileExistsCache . get ( path )
28+ if ( cached !== undefined ) {
29+ return cached
30+ }
31+ const exists = existsSync ( path )
32+ fileExistsCache . set ( path , exists )
33+ return exists
34+ }
35+
36+ /** Clears the file existence cache. Call when files may have changed. */
37+ export function clearImportCache ( ) : void {
38+ fileExistsCache . clear ( )
39+ }
40+
741/**
842 * Resolves a module path to its Python file.
943 * Checks for direct .py file first, then package __init__.py
1044 * (matching Python's import resolution order).
1145 */
1246function resolvePythonModule ( basePath : string ) : string | null {
13- if ( existsSync ( `${ basePath } .py` ) ) {
14- return `${ basePath } .py`
47+ const pyPath = `${ basePath } .py`
48+ if ( cachedExistsSync ( pyPath ) ) {
49+ return pyPath
1550 }
16- if ( existsSync ( `${ basePath } /__init__.py` ) ) {
17- return `${ basePath } /__init__.py`
51+ const initPath = join ( basePath , "__init__.py" )
52+ if ( cachedExistsSync ( initPath ) ) {
53+ return initPath
1854 }
1955 return null
2056}
@@ -41,30 +77,50 @@ function findImportByExportedName(
4177}
4278
4379/**
44- * Base resolution of a module import to its file path.
45- * Handles both relative and absolute imports.
80+ * Converts a Python module path to a filesystem directory path.
81+ *
82+ * Examples (modulePath, relativeDots → result):
83+ * Absolute: ("app.api.routes", 0) from projectRoot="/project" → "/project/app/api/routes"
84+ * Relative: ("routes", 1) from "/project/app/api/main.py" → "/project/app/api/routes"
85+ * Relative: ("routes", 2) from "/project/app/api/main.py" → "/project/app/routes"
4686 */
47- export function resolveImport (
87+ function modulePathToDir (
4888 importInfo : Pick < ImportInfo , "modulePath" | "isRelative" | "relativeDots" > ,
4989 currentFilePath : string ,
5090 projectRoot : string ,
51- ) : string | null {
52- let resolvedPath : string
53-
91+ ) : string {
92+ let baseDir : string
5493 if ( importInfo . isRelative ) {
5594 // For relative imports, go up 'relativeDots' directories from current file
56- let currentDir = dirname ( currentFilePath )
95+ baseDir = dirname ( currentFilePath )
5796 for ( let i = 1 ; i < importInfo . relativeDots ; i ++ ) {
58- currentDir = dirname ( currentDir )
97+ baseDir = dirname ( baseDir )
5998 }
60- resolvedPath = importInfo . modulePath
61- ? `${ currentDir } /${ importInfo . modulePath . replace ( / \. / g, "/" ) } `
62- : currentDir
63- // Absolute import
6499 } else {
65- resolvedPath = `${ projectRoot } /${ importInfo . modulePath . replace ( / \. / g, "/" ) } `
100+ baseDir = projectRoot
101+ }
102+
103+ if ( importInfo . modulePath ) {
104+ return join ( baseDir , ...importInfo . modulePath . split ( "." ) )
66105 }
106+ return baseDir
107+ }
67108
109+ /**
110+ * Resolves a module import to its file path.
111+ *
112+ * Examples:
113+ * "from app.api import routes" → "/project/app/api/routes.py" or "/project/app/api/routes/__init__.py"
114+ * "from .routes import users" → "/project/app/api/routes.py" or "/project/app/api/routes/__init__.py"
115+ *
116+ * Returns null if the module doesn't exist (may be a namespace package).
117+ */
118+ export function resolveImport (
119+ importInfo : Pick < ImportInfo , "modulePath" | "isRelative" | "relativeDots" > ,
120+ currentFilePath : string ,
121+ projectRoot : string ,
122+ ) : string | null {
123+ const resolvedPath = modulePathToDir ( importInfo , currentFilePath , projectRoot )
68124 return resolvePythonModule ( resolvedPath )
69125}
70126
@@ -83,22 +139,24 @@ export function resolveNamedImport(
83139 parser ?: Parser ,
84140) : string | null {
85141 const basePath = resolveImport ( importInfo , currentFilePath , projectRoot )
86- if ( ! basePath ) {
87- return null
88- }
89142
90- const baseDir = dirname ( basePath )
143+ // Calculate base directory for named import resolution.
144+ // For namespace packages (directories without __init__.py), basePath will be null,
145+ // so we compute the directory path directly from the module path.
146+ const baseDir = basePath
147+ ? dirname ( basePath )
148+ : modulePathToDir ( importInfo , currentFilePath , projectRoot )
91149
92150 for ( const name of importInfo . names ) {
93151 // Try direct file: from .routes import users -> routes/users.py
94- const namedPath = ` ${ baseDir } / ${ name . replace ( / \. / g , "/" ) } `
152+ const namedPath = join ( baseDir , ... name . split ( "." ) )
95153 const resolved = resolvePythonModule ( namedPath )
96154 if ( resolved ) {
97155 return resolved
98156 }
99157
100158 // Try re-exports: from .routes import users where routes/__init__.py re-exports users
101- if ( basePath . endsWith ( "__init__.py" ) && parser ) {
159+ if ( basePath ? .endsWith ( "__init__.py" ) && parser ) {
102160 const analysis = analyzeFile ( basePath , parser )
103161 const imp = analysis && findImportByExportedName ( analysis . imports , name )
104162 if ( imp ) {
0 commit comments