Skip to content

(Python) Resolve FQNs for references

Assuming we have a set of references captured by ast-grep, the next step is to compute a fully qualified name (FQN) for each one. This is significantly more complex than computing FQNs for definitions and, due to Python's dynamic nature, perfect static resolution will not be possible.

We can achieve "pretty good" resolution, though, by combining a variety of analysis techniques, like scope tracking, data flow analysis, type inference, inheritance resolution, etc. There are many cases to handle, ranging from common to rare/complex. Below are some examples of the trickier cases. As work on this progresses, we can decide which of these to handle in the first iteration based on the effort.

Note: If a reference is of an imported function, fully resolving the location of the function definition is out of scope (no pun intended) for this issue. That work will happen in the Knowledge Graph Indexer, and will involve building an import graph from all parsed files in a codebase.

Examples

Variable reassignments:

def foo():
    pass

def bar():
    pass

# Simple reassignment
fizz = foo
fizz()

# Multiple reassignments
fizz = foo
buzz = fizz
buzz()

# Conditional reassignment
fizz = foo if condition else bar
fizz() # foo or bar? Ambiguous

Higher-order functions:

# Functions returning functions
def get_fn():
    if condition:
        return foo
    else:
        return bar

my_fn = get_fn()
my_fn() # foo or bar? Ambiguous

# Callbacks
def call_fn(func):
    return func() # foo or bar? Ambiguous

call_fn(foo)
call_fn(bar)

# Closures
def outer():
    def inner():
        pass
    return inner

fn = outer()
fn() # outer.<locals>.inner

Dynamic attribute access:

funcs = {
   "foo": foo,
   "bar": bar
}
func = funcs["foo"]
func()

Aliased imports:

from utils.stuff import foo as bar # Alias + shadowing
bar() # utils.stuff.foo

Method resolution:

class MyClass:
    @property
    def my_property(self):
        return self._my_property

    @staticmethod
    def static_method():
        pass

    @classmethod
    def class_method(cls):
        pass

    def instance_method(self):
        pass

# Different ways to call
MyClass.static_method() # MyClass.static_method
MyClass.class_method() # MyClass.class_method

my_class = MyClass() # MyClass.__call__
my_class.instance_method() # MyClass.instance_method
my_class.static_method() # MyClass.static_method
my_class.my_property # MyClass.my_property.fget

Inheritance and super calls:

class Base:
    def foo(self):
        pass

class Child(Base):
    def foo(self):
        super().foo() # Base.foo

    def bar(self):
        self.foo() # Child.foo (not Base.foo)

This list of examples is not comprehensive and there are many more cases that will pop up. Whenever a decision is made to not handle a case, we'll note it in the limitations section of the parent epic.

Edited by Jonathan Shobrook