Skip to content

Index

axm_ast

axm-ast — AST introspection CLI for AI agents, powered by tree-sitter.

This package provides deterministic, fast parsing of Python libraries to extract structured information (functions, classes, imports, docstrings, call graphs) at multiple granularity levels.

Example

from axm_ast import analyze_package from pathlib import Path

pkg = analyze_package(Path("src/mylib")) [m.path.name for m in pkg.modules] ['__init__.py', 'core.py', 'utils.py']

CallSite

Bases: BaseModel

A single function/method call location.

Example

cs = CallSite( ... module="cli", ... symbol="greet", ... line=42, ... column=8, ... context="main", ... call_expression='greet("world")', ... ) cs.module 'cli'

Source code in packages/axm-ast/src/axm_ast/models/calls.py
Python
class CallSite(BaseModel):
    """A single function/method call location.

    Example:
        >>> cs = CallSite(
        ...     module="cli",
        ...     symbol="greet",
        ...     line=42,
        ...     column=8,
        ...     context="main",
        ...     call_expression='greet("world")',
        ... )
        >>> cs.module
        'cli'
    """

    model_config = ConfigDict(extra="forbid")

    module: str = Field(description="Dotted module name")
    symbol: str = Field(description="Called symbol name")
    line: int = Field(description="Line number (1-indexed)")
    column: int = Field(description="Column offset (0-indexed)")
    context: str | None = Field(
        default=None,
        description="Enclosing function/class name",
    )
    call_expression: str = Field(
        description="Raw text of the call expression",
    )
    confidence: float = Field(
        default=1.0,
        description=(
            "Syntactic match confidence in [0, 1]. Matching is by name only "
            "(the receiver type is never resolved), so this is a heuristic "
            "derived purely from call syntax: 1.0 for a direct call or a "
            "``self``/``cls`` method call, lower for an attribute call on "
            "another receiver (``obj.foo()``), which is more likely to be a "
            "false-positive caller of a like-named symbol. Additive and "
            "backward-compatible: defaults to 1.0 and never changes which "
            "call-sites are returned."
        ),
    )

ClassInfo

Bases: BaseModel

Metadata for a single class.

Example

cls = ClassInfo(name="Parser", line_start=1, line_end=50) cls.is_public True

Source code in packages/axm-ast/src/axm_ast/models/nodes.py
Python
class ClassInfo(BaseModel):
    """Metadata for a single class.

    Example:
        >>> cls = ClassInfo(name="Parser", line_start=1, line_end=50)
        >>> cls.is_public
        True
    """

    model_config = ConfigDict(extra="forbid")

    name: str = Field(description="Class name")
    bases: list[str] = Field(default_factory=list, description="Base class names")
    methods: list[FunctionInfo] = Field(default_factory=list, description="Methods")
    docstring: str | None = Field(default=None, description="Docstring content")
    decorators: list[str] = Field(default_factory=list, description="Decorator names")
    line_start: int = Field(description="Start line (1-indexed)")
    line_end: int = Field(description="End line (1-indexed)")

    @property
    def is_public(self) -> bool:
        """Whether this class is part of the public API."""
        return not self.name.startswith("_")
is_public property

Whether this class is part of the public API.

DeadSymbol dataclass

An unreferenced symbol detected by dead code analysis.

Source code in packages/axm-ast/src/axm_ast/core/dead_code.py
Python
@dataclass(frozen=True, slots=True)
class DeadSymbol:
    """An unreferenced symbol detected by dead code analysis."""

    name: str
    module_path: str
    line: int
    kind: str  # "function", "method", "class"

FlowStep

Bases: BaseModel

A single step in a traced execution flow.

Source code in packages/axm-ast/src/axm_ast/core/flows.py
Python
class FlowStep(BaseModel):
    """A single step in a traced execution flow."""

    model_config = ConfigDict(extra="forbid")

    name: str
    module: str
    line: int
    depth: int
    chain: list[str]
    resolved_module: str | None = Field(
        default=None,
        description="Dotted module path when resolved across modules",
    )
    source: str | None = Field(
        default=None,
        description="Source code of the symbol (when detail='source')",
    )

FunctionInfo

Bases: BaseModel

Metadata for a single function or method.

Example

fn = FunctionInfo(name="parse", line_start=10, line_end=25) fn.is_public True

Source code in packages/axm-ast/src/axm_ast/models/nodes.py
Python
class FunctionInfo(BaseModel):
    """Metadata for a single function or method.

    Example:
        >>> fn = FunctionInfo(name="parse", line_start=10, line_end=25)
        >>> fn.is_public
        True
    """

    model_config = ConfigDict(extra="forbid")

    name: str = Field(description="Function/method name")
    params: list[ParameterInfo] = Field(default_factory=list, description="Parameters")
    return_type: str | None = Field(default=None, description="Return type annotation")
    docstring: str | None = Field(default=None, description="Docstring content")
    decorators: list[str] = Field(default_factory=list, description="Decorator names")
    kind: FunctionKind = Field(
        default=FunctionKind.FUNCTION, description="Callable classification"
    )
    line_start: int = Field(description="Start line (1-indexed)")
    line_end: int = Field(description="End line (1-indexed)")
    is_async: bool = Field(default=False, description="Whether function is async")
    signature: str | None = Field(default=None, description="Human-readable signature")

    @property
    def is_public(self) -> bool:
        """Whether this function is part of the public API."""
        return not self.name.startswith("_")

    def model_post_init(self, __context: object) -> None:
        """Compute signature if not explicitly provided.

        Strips ``Annotated[T, ...]`` wrappers from parameter and return-type
        annotations so that generated signatures show only the base type.
        """
        if self.signature is None:
            params_str = ", ".join(
                p.name + (f": {_strip_annotated(p.annotation)}" if p.annotation else "")
                for p in self.params
            )
            ret_type = _strip_annotated(self.return_type) if self.return_type else None
            ret = f" -> {ret_type}" if ret_type else ""
            prefix = "async " if self.is_async else ""
            self.signature = f"{prefix}def {self.name}({params_str}){ret}"
is_public property

Whether this function is part of the public API.

model_post_init(__context)

Compute signature if not explicitly provided.

Strips Annotated[T, ...] wrappers from parameter and return-type annotations so that generated signatures show only the base type.

Source code in packages/axm-ast/src/axm_ast/models/nodes.py
Python
def model_post_init(self, __context: object) -> None:
    """Compute signature if not explicitly provided.

    Strips ``Annotated[T, ...]`` wrappers from parameter and return-type
    annotations so that generated signatures show only the base type.
    """
    if self.signature is None:
        params_str = ", ".join(
            p.name + (f": {_strip_annotated(p.annotation)}" if p.annotation else "")
            for p in self.params
        )
        ret_type = _strip_annotated(self.return_type) if self.return_type else None
        ret = f" -> {ret_type}" if ret_type else ""
        prefix = "async " if self.is_async else ""
        self.signature = f"{prefix}def {self.name}({params_str}){ret}"

FunctionKind

Bases: StrEnum

Classification of a callable based on its decorators.

Source code in packages/axm-ast/src/axm_ast/models/nodes.py
Python
class FunctionKind(enum.StrEnum):
    """Classification of a callable based on its decorators."""

    FUNCTION = "function"
    METHOD = "method"
    PROPERTY = "property"
    CLASSMETHOD = "classmethod"
    STATICMETHOD = "staticmethod"
    ABSTRACT = "abstract"

ImportInfo

Bases: BaseModel

A single import statement.

Example

imp = ImportInfo(module="pathlib", names=["Path"]) imp.is_relative False

Source code in packages/axm-ast/src/axm_ast/models/nodes.py
Python
class ImportInfo(BaseModel):
    """A single import statement.

    Example:
        >>> imp = ImportInfo(module="pathlib", names=["Path"])
        >>> imp.is_relative
        False
    """

    model_config = ConfigDict(extra="forbid")

    module: str | None = Field(
        default=None, description="Module path (None for 'import x')"
    )
    names: list[str] = Field(default_factory=list, description="Imported names")
    alias: str | None = Field(default=None, description="Alias (as ...)")
    is_relative: bool = Field(default=False, description="Relative import")
    level: int = Field(default=0, description="Number of leading dots")

ModuleInfo

Bases: BaseModel

Full introspection result for a single Python module.

Example

mod = ModuleInfo(path=Path("foo.py")) len(mod.functions) 0

Source code in packages/axm-ast/src/axm_ast/models/nodes.py
Python
class ModuleInfo(BaseModel):
    """Full introspection result for a single Python module.

    Example:
        >>> mod = ModuleInfo(path=Path("foo.py"))
        >>> len(mod.functions)
        0
    """

    model_config = ConfigDict(extra="forbid", arbitrary_types_allowed=True)

    path: Path = Field(description="File path")
    name: str | None = Field(default=None, description="Module name")
    docstring: str | None = Field(default=None, description="Module-level docstring")
    functions: list[FunctionInfo] = Field(
        default_factory=list, description="Top-level functions"
    )
    classes: list[ClassInfo] = Field(
        default_factory=list, description="Top-level classes"
    )
    imports: list[ImportInfo] = Field(
        default_factory=list, description="Import statements"
    )
    variables: list[VariableInfo] = Field(
        default_factory=list, description="Module-level variables"
    )
    all_exports: list[str] | None = Field(
        default=None,
        description="Contents of __all__, None if not defined",
    )

    @property
    def public_functions(self) -> list[FunctionInfo]:
        """Functions that are part of the public API."""
        if self.all_exports is not None:
            return [f for f in self.functions if f.name in self.all_exports]
        return [f for f in self.functions if f.is_public]

    @property
    def public_classes(self) -> list[ClassInfo]:
        """Classes that are part of the public API."""
        if self.all_exports is not None:
            return [c for c in self.classes if c.name in self.all_exports]
        return [c for c in self.classes if c.is_public]
public_classes property

Classes that are part of the public API.

public_functions property

Functions that are part of the public API.

PackageInfo

Bases: BaseModel

Full introspection result for a Python package.

Example

pkg = PackageInfo(name="mylib", root=Path("src/mylib")) len(pkg.modules) 0

Source code in packages/axm-ast/src/axm_ast/models/nodes.py
Python
class PackageInfo(BaseModel):
    """Full introspection result for a Python package.

    Example:
        >>> pkg = PackageInfo(name="mylib", root=Path("src/mylib"))
        >>> len(pkg.modules)
        0
    """

    model_config = ConfigDict(extra="forbid")

    name: str = Field(description="Package name")
    root: Path = Field(description="Package root directory")
    modules: list[ModuleInfo] = Field(default_factory=list, description="All modules")
    dependency_edges: list[tuple[str, str]] = Field(
        default_factory=list,
        description="Internal import edges (from_module, to_module)",
    )

    @property
    def public_api(self) -> list[FunctionInfo | ClassInfo]:
        """All public functions and classes across the package."""
        result: list[FunctionInfo | ClassInfo] = []
        for mod in self.modules:
            result.extend(mod.public_functions)
            result.extend(mod.public_classes)
        return result

    @property
    def module_names(self) -> list[str]:
        """List of dotted module names."""
        names: list[str] = []
        for mod in self.modules:
            try:
                rel = mod.path.relative_to(self.root)
            except ValueError:
                names.append(mod.path.stem)
                continue
            parts = list(rel.with_suffix("").parts)
            if parts and parts[-1] == "__init__":
                parts = parts[:-1]
            if parts:
                names.append(".".join(parts))
            else:
                names.append(self.name)
        return names
module_names property

List of dotted module names.

public_api property

All public functions and classes across the package.

ParameterInfo

Bases: BaseModel

A single function/method parameter.

Example

p = ParameterInfo(name="path", annotation="Path", default="None") p.name 'path'

Source code in packages/axm-ast/src/axm_ast/models/nodes.py
Python
class ParameterInfo(BaseModel):
    """A single function/method parameter.

    Example:
        >>> p = ParameterInfo(name="path", annotation="Path", default="None")
        >>> p.name
        'path'
    """

    model_config = ConfigDict(extra="forbid")

    name: str = Field(description="Parameter name")
    annotation: str | None = Field(
        default=None, description="Type annotation as string"
    )
    default: str | None = Field(default=None, description="Default value as string")

StructuralDiffResult

Bases: TypedDict

Output of :func:structural_diff.

total=False so the error variant ({"error": str}) also matches.

Source code in packages/axm-ast/src/axm_ast/core/structural_diff.py
Python
class StructuralDiffResult(TypedDict, total=False):
    """Output of :func:`structural_diff`.

    ``total=False`` so the error variant (``{"error": str}``) also matches.
    """

    added: list[SymbolEntry]
    removed: list[SymbolEntry]
    modified: list[ModifiedSymbol]
    summary: DiffSummary
    error: str

VariableInfo

Bases: BaseModel

A module-level variable or constant.

Example

v = VariableInfo(name="all", line=5) v.name 'all'

Source code in packages/axm-ast/src/axm_ast/models/nodes.py
Python
class VariableInfo(BaseModel):
    """A module-level variable or constant.

    Example:
        >>> v = VariableInfo(name="__all__", line=5)
        >>> v.name
        '__all__'
    """

    model_config = ConfigDict(extra="forbid")

    name: str = Field(description="Variable name")
    annotation: str | None = Field(default=None, description="Type annotation")
    value_repr: str | None = Field(
        default=None, description="Short repr of assigned value"
    )
    line: int = Field(description="Line number (1-indexed)")

WorkspaceInfo

Bases: BaseModel

Multi-package workspace introspection result.

Aggregates multiple PackageInfo from a uv workspace.

Example

ws = WorkspaceInfo(name="my-ws", root=Path("/ws")) len(ws.packages) 0

Source code in packages/axm-ast/src/axm_ast/models/nodes.py
Python
class WorkspaceInfo(BaseModel):
    """Multi-package workspace introspection result.

    Aggregates multiple ``PackageInfo`` from a uv workspace.

    Example:
        >>> ws = WorkspaceInfo(name="my-ws", root=Path("/ws"))
        >>> len(ws.packages)
        0
    """

    model_config = ConfigDict(extra="forbid")

    name: str = Field(description="Workspace name")
    root: Path = Field(description="Workspace root directory")
    packages: list[PackageInfo] = Field(
        default_factory=list, description="All packages in workspace"
    )
    package_edges: list[tuple[str, str]] = Field(
        default_factory=list,
        description="Inter-package dependency edges (from_pkg, to_pkg)",
    )

analyze_package(path)

Analyze a Python package directory.

Discovers all .py files, parses them with tree-sitter, and builds a complete PackageInfo with dependency edges.

Parameters:

Name Type Description Default
path Path

Path to the package root directory.

required

Returns:

Type Description
PackageInfo

PackageInfo with all modules and dependency edges.

Raises:

Type Description
ValueError

If path is not a directory.

Example

pkg = analyze_package(Path("src/mylib")) pkg.name 'mylib'

Source code in packages/axm-ast/src/axm_ast/core/analyzer.py
Python
def analyze_package(path: Path) -> PackageInfo:
    """Analyze a Python package directory.

    Discovers all ``.py`` files, parses them with tree-sitter, and
    builds a complete ``PackageInfo`` with dependency edges.

    Args:
        path: Path to the package root directory.

    Returns:
        PackageInfo with all modules and dependency edges.

    Raises:
        ValueError: If path is not a directory.

    Example:
        >>> pkg = analyze_package(Path("src/mylib"))
        >>> pkg.name
        'mylib'
    """
    path = Path(path).resolve()
    if not path.is_dir():
        msg = f"{path} is not a directory"
        raise ValueError(msg)

    # Detect src-layout: src/<pkg>/__init__.py
    src_dir = path / "src"
    if src_dir.is_dir():
        pkg_dirs = sorted(
            (
                child
                for child in src_dir.iterdir()
                if child.is_dir() and (child / "__init__.py").exists()
            ),
            key=lambda child: child.name,
        )
        if pkg_dirs:
            chosen = pkg_dirs[0]
            if len(pkg_dirs) > 1:
                skipped = ", ".join(child.name for child in pkg_dirs[1:])
                logger.warning(
                    "Multiple packages under %s; selected %r (alphabetically "
                    "first), skipped: %s",
                    src_dir,
                    chosen.name,
                    skipped,
                )
            path = chosen

    t0 = time.perf_counter()

    # Discover all .py files, skipping virtual envs and caches
    py_files = sorted(_discover_py_files(path))
    modules: list[ModuleInfo] = []
    for py_file in py_files:
        modules.append(extract_module_info(py_file))

    # Build dependency edges from internal imports
    dep_edges = _build_edges(modules, path)

    pkg = PackageInfo(
        name=path.name,
        root=path,
        modules=modules,
        dependency_edges=dep_edges,
    )

    elapsed = time.perf_counter() - t0
    logger.debug("Analyzed %s in %.2fs (%d modules)", path.name, elapsed, len(modules))

    return pkg

analyze_workspace(path, *, detected=None)

Analyze all packages in a uv workspace.

Discovers workspace members, analyzes each with analyze_package(), and builds inter-package dependency edges.

Parameters:

Name Type Description Default
path Path

Path to workspace root.

required
detected WorkspaceInfo | None

A WorkspaceInfo already produced by :func:detect_workspace for the same path. When provided, the redundant internal detection is skipped (the caller has already paid for it). When None (default), detection runs here as before.

None

Returns:

Type Description
WorkspaceInfo

WorkspaceInfo with all packages and dependency edges.

Raises:

Type Description
ValueError

If path is not a workspace root.

Example

ws = analyze_workspace(Path("/path/to/workspace")) len(ws.packages) > 0 True

Source code in packages/axm-ast/src/axm_ast/core/workspace.py
Python
def analyze_workspace(
    path: Path, *, detected: WorkspaceInfo | None = None
) -> WorkspaceInfo:
    """Analyze all packages in a uv workspace.

    Discovers workspace members, analyzes each with ``analyze_package()``,
    and builds inter-package dependency edges.

    Args:
        path: Path to workspace root.
        detected: A ``WorkspaceInfo`` already produced by
            :func:`detect_workspace` for the same ``path``. When provided,
            the redundant internal detection is skipped (the caller has
            already paid for it). When ``None`` (default), detection runs
            here as before.

    Returns:
        WorkspaceInfo with all packages and dependency edges.

    Raises:
        ValueError: If path is not a workspace root.

    Example:
        >>> ws = analyze_workspace(Path("/path/to/workspace"))
        >>> len(ws.packages) > 0
        True
    """
    path = Path(path).resolve()
    ws = detected if detected is not None else detect_workspace(path)
    if ws is None:
        msg = f"{path} is not a uv workspace"
        raise ValueError(msg)

    pyproject_text = (path / "pyproject.toml").read_text()
    raw_members = parse_workspace_members(pyproject_text)
    members = _expand_workspace_members(path, raw_members)

    # Build member_names from project names in each member's pyproject.toml
    member_names: set[str] = set()
    for member in members:
        member_pyproject = path / member / "pyproject.toml"
        if member_pyproject.exists():
            name = _parse_project_name(member_pyproject.read_text())
            if name:
                member_names.add(name)
        member_names.add(Path(member).name)

    packages: list[PackageInfo] = []
    for member in members:
        member_path = path / member
        if not member_path.is_dir():
            logger.warning("Workspace member %s not found, skipping", member)
            continue

        pkg_src = _find_package_source(member_path)
        if pkg_src is None:
            logger.warning("No source package found in %s, skipping", member)
            continue

        try:
            pkg = get_package(pkg_src)
            packages.append(pkg)
        except (OSError, ValueError):
            logger.warning("Failed to analyze %s, skipping", member, exc_info=True)

    # Build inter-package dependency edges
    package_edges = _build_package_edges(path, members, member_names)

    ws.packages = packages
    ws.package_edges = package_edges
    return ws

build_workspace_module_graph(ws)

Build a merged module-level import graph across all packages.

Reuses :func:build_import_graph per package and namespaces every node as {package_name}.{module}. Cross-package import targets are resolved to their owning package so edges stay namespaced rather than bare module names (lets anvil tell which package each node belongs to).

Parameters:

Name Type Description Default
ws WorkspaceInfo

Analyzed workspace info with .packages.

required

Returns:

Type Description
dict[str, list[str]]

Adjacency-list dict mapping {pkg}.{module} to the list of

dict[str, list[str]]

{pkg}.{module} nodes it imports.

Example

graph = build_workspace_module_graph(ws) graph["axm-mcp.cli"] ['axm.tools']

Source code in packages/axm-ast/src/axm_ast/core/workspace.py
Python
def build_workspace_module_graph(ws: WorkspaceInfo) -> dict[str, list[str]]:
    """Build a merged module-level import graph across all packages.

    Reuses :func:`build_import_graph` per package and namespaces every
    node as ``{package_name}.{module}``. Cross-package import targets are
    resolved to their owning package so edges stay namespaced rather than
    bare module names (lets anvil tell which package each node belongs to).

    Args:
        ws: Analyzed workspace info with ``.packages``.

    Returns:
        Adjacency-list dict mapping ``{pkg}.{module}`` to the list of
        ``{pkg}.{module}`` nodes it imports.

    Example:
        >>> graph = build_workspace_module_graph(ws)
        >>> graph["axm-mcp.cli"]
        `['axm.tools']`
    """
    module_sets = {pkg.name: _package_module_names(pkg) for pkg in ws.packages}
    owners: dict[str, str] = {}
    for pkg in ws.packages:
        for name in module_sets[pkg.name]:
            owners.setdefault(name, pkg.name)

    graph: dict[str, list[str]] = {}
    for pkg in ws.packages:
        own_modules = module_sets[pkg.name]
        for src, targets in build_import_graph(pkg).items():
            src_node = f"{pkg.name}.{src}"
            graph.setdefault(src_node, []).extend(
                _resolve_target_node(target, pkg.name, own_modules, owners)
                for target in targets
            )
    # Cross-package edges are absent from per-package dependency_edges, so
    # derive them from raw module imports (AC5).
    for pkg in ws.packages:
        _collect_cross_package_edges(pkg, module_sets, graph)
    return graph

find_callers(pkg, symbol)

Find all call-sites of a given symbol across a package.

Searches every module in the package for calls matching the given symbol name. Uses cached call-sites when available to avoid re-parsing files on repeated queries.

.. warning:: Matching is by name only. The receiver is ignored, so self.foo(), obj.foo() and a bare foo() all collapse to the name foo. This is an intrinsic tree-sitter limitation — no type inference is performed — and it may surface false-positive callers that call a distinct, like-named symbol on a different receiver. Each returned :class:~axm_ast.models.calls.CallSite carries a syntactic confidence (1.0 for direct/self calls, lower for an attribute call on another receiver) to help triage this ambiguity. The set of callers returned is never affected by confidence.

Parameters:

Name Type Description Default
pkg PackageInfo

Analyzed package info.

required
symbol str

Name of the function/method to search for.

required

Returns:

Type Description
list[CallSite]

List of CallSite objects where the symbol is called.

Example

results = find_callers(pkg, "greet") results[0].module 'cli'

Source code in packages/axm-ast/src/axm_ast/core/callers.py
Python
def find_callers(
    pkg: PackageInfo,
    symbol: str,
) -> list[CallSite]:
    """Find all call-sites of a given symbol across a package.

    Searches every module in the package for calls matching
    the given symbol name.  Uses cached call-sites when available
    to avoid re-parsing files on repeated queries.

    .. warning:: Matching is **by name only**. The receiver is ignored, so
        ``self.foo()``, ``obj.foo()`` and a bare ``foo()`` all collapse to the
        name ``foo``. This is an intrinsic tree-sitter limitation — no type
        inference is performed — and it may surface **false-positive callers**
        that call a *distinct*, like-named symbol on a different receiver.
        Each returned :class:`~axm_ast.models.calls.CallSite` carries a
        syntactic ``confidence`` (1.0 for direct/``self`` calls, lower for an
        attribute call on another receiver) to help triage this ambiguity.
        The set of callers returned is never affected by ``confidence``.

    Args:
        pkg: Analyzed package info.
        symbol: Name of the function/method to search for.

    Returns:
        List of CallSite objects where the symbol is called.

    Example:
        >>> results = find_callers(pkg, "greet")
        >>> results[0].module
        'cli'
    """
    index = _cached_call_index(pkg.root)
    if index is not None:
        # Return a fresh list (the CallSite objects are still shared with the
        # cache, matching the previous linear-scan behavior).
        return list(index.get(symbol, ()))
    return [c for c in _iter_fresh_calls(pkg) if c.symbol == symbol]

find_dead_code(pkg, *, include_tests=False)

Detect unreferenced symbols across a package.

Algorithm
  1. Enumerate all functions and classes across all modules.
  2. For each symbol, check if it has any callers or references.
  3. Apply exemptions (dunders, tests, exports, decorators, entry points, etc.).
  4. For methods, check override chains.
  5. Also scan a sibling tests/ directory for callers.
  6. Detect lazy imports inside function bodies.

.. warning:: Reference matching is by name only. Liveness is decided against a single global set[str] of referenced names, so a dead symbol that shares its name with a live, distinct symbol elsewhere is wrongly considered referenced and omitted from the result (a false negative). This is an intrinsic tree-sitter limitation — no type or scope inference is performed — and mirrors the homonym ambiguity documented on :func:~axm_ast.core.callers.find_callers. Symbols reported as dead are therefore high-confidence; truly-dead symbols that are homonymous with a live one may be silently missed.

Parameters:

Name Type Description Default
pkg PackageInfo

Analyzed package from analyze_package().

required
include_tests bool

If True, also scan modules inside tests/ directories. Defaults to False.

False

Returns:

Type Description
list[DeadSymbol]

List of dead symbols, sorted by module path then line number.

Source code in packages/axm-ast/src/axm_ast/core/dead_code.py
Python
def find_dead_code(
    pkg: PackageInfo,
    *,
    include_tests: bool = False,
) -> list[DeadSymbol]:
    """Detect unreferenced symbols across a package.

    Algorithm:
        1. Enumerate all functions and classes across all modules.
        2. For each symbol, check if it has any callers or references.
        3. Apply exemptions (dunders, tests, exports, decorators,
           entry points, etc.).
        4. For methods, check override chains.
        5. Also scan a sibling ``tests/`` directory for callers.
        6. Detect lazy imports inside function bodies.

    .. warning:: Reference matching is **by name only**. Liveness is decided
        against a single global ``set[str]`` of referenced names, so a dead
        symbol that shares its name with a live, distinct symbol elsewhere is
        wrongly considered referenced and **omitted from the result** (a false
        negative). This is an intrinsic tree-sitter limitation — no type or
        scope inference is performed — and mirrors the homonym ambiguity
        documented on :func:`~axm_ast.core.callers.find_callers`. Symbols
        reported as dead are therefore high-confidence; truly-dead symbols that
        are homonymous with a live one may be silently missed.

    Args:
        pkg: Analyzed package from ``analyze_package()``.
        include_tests: If ``True``, also scan modules inside ``tests/``
            directories. Defaults to ``False``.

    Returns:
        List of dead symbols, sorted by module path then line number.
    """
    dead: list[DeadSymbol] = []

    test_pkg = _load_test_package(pkg.root)
    all_refs = _gather_all_refs(pkg, test_pkg)

    entry_points = _load_entry_point_symbols(pkg.root)

    # Also exempt framework-detected entry points (decorators, test_, __main__).
    from axm_ast.core.flows import find_entry_points

    for ep in find_entry_points(pkg):
        entry_points.add(ep.name)

    namespace_modules = find_namespace_modules(pkg)

    ctx = _ScanContext(
        entry_points=entry_points,
        all_refs=all_refs,
        extra_pkg=test_pkg,
        namespace_modules=namespace_modules,
    )

    for mod in pkg.modules:
        # Skip test files — they are consumers, not targets.
        path_name = mod.path.name
        if path_name.startswith("test_") or path_name == "conftest.py":
            continue
        if not include_tests and _is_in_tests_dir(mod.path):
            continue

        dead.extend(_scan_functions(mod, pkg, ctx))
        dead.extend(_scan_classes(mod, pkg, ctx))

    dead.sort(key=lambda d: (d.module_path, d.line))
    return dead

search_symbols(pkg, *, name=None, returns=None, kind=None, inherits=None)

Search for symbols across a package with filters.

All filters are AND-combined. A symbol must match all provided filters to be included in results.

Parameters:

Name Type Description Default
pkg PackageInfo

Analyzed package info.

required
name str | None

Filter by symbol name (substring match).

None
returns str | None

Filter functions by return type (substring match).

None
kind SymbolKind | None

Filter by SymbolKind (function, method, property, classmethod, staticmethod, abstract, class, variable).

None
inherits str | None

Filter classes by base class name.

None

Returns:

Type Description
list[tuple[str, FunctionInfo | ClassInfo | VariableInfo]]

List of (module_name, symbol) tuples for matching symbols.

Example

results = search_symbols(pkg, returns="str") [sym.name for _, sym in results] ['greet', 'version']

Source code in packages/axm-ast/src/axm_ast/core/analyzer.py
Python
def search_symbols(
    pkg: PackageInfo,
    *,
    name: str | None = None,
    returns: str | None = None,
    kind: SymbolKind | None = None,
    inherits: str | None = None,
) -> list[tuple[str, FunctionInfo | ClassInfo | VariableInfo]]:
    """Search for symbols across a package with filters.

    All filters are AND-combined. A symbol must match all provided
    filters to be included in results.

    Args:
        pkg: Analyzed package info.
        name: Filter by symbol name (substring match).
        returns: Filter functions by return type (substring match).
        kind: Filter by SymbolKind (function, method, property,
            classmethod, staticmethod, abstract, class, variable).
        inherits: Filter classes by base class name.

    Returns:
        List of (module_name, symbol) tuples for matching symbols.

    Example:
        >>> results = search_symbols(pkg, returns="str")
        >>> [sym.name for _, sym in results]
        `['greet', 'version']`
    """
    results: list[tuple[str, FunctionInfo | ClassInfo | VariableInfo]] = []

    for mod in pkg.modules:
        mod_dotted = mod.name or module_dotted_name(mod.path, pkg.root)
        for sym in _search_module(
            mod,
            name=name,
            returns=returns,
            kind=kind,
            inherits=inherits,
        ):
            results.append((mod_dotted, sym))

    return results

structural_diff(pkg_path, base, head)

Compare two git refs at symbol level.

Uses git worktrees to checkout the base ref, runs analyze_package() on both versions, and diffs the symbol sets.

Parameters:

Name Type Description Default
pkg_path Path

Path to the package directory.

required
base str

Base git ref (branch, tag, or commit).

required
head str

Head git ref (branch, tag, or commit).

required

Returns:

Type Description
StructuralDiffResult

Dict with added, removed, modified, and

StructuralDiffResult

summary keys. On error, returns a dict with an

StructuralDiffResult

error key.

Example

result = structural_diff(Path("src/mylib"), "main", "feature") len(result["added"]) 3

Source code in packages/axm-ast/src/axm_ast/core/structural_diff.py
Python
def structural_diff(
    pkg_path: Path,
    base: str,
    head: str,
) -> StructuralDiffResult:
    """Compare two git refs at symbol level.

    Uses git worktrees to checkout the *base* ref, runs
    ``analyze_package()`` on both versions, and diffs the
    symbol sets.

    Args:
        pkg_path: Path to the package directory.
        base: Base git ref (branch, tag, or commit).
        head: Head git ref (branch, tag, or commit).

    Returns:
        Dict with ``added``, ``removed``, ``modified``, and
        ``summary`` keys.  On error, returns a dict with an
        ``error`` key.

    Example:
        >>> result = structural_diff(Path("src/mylib"), "main", "feature")
        >>> len(result["added"])
        3
    """
    pkg_path = pkg_path.resolve()

    validated = _validate_diff_inputs(pkg_path, base, head)
    if isinstance(validated, dict):
        return StructuralDiffResult(error=validated["error"])
    project_root, pkg_rel = validated

    head_symbols = _extract_symbols_at_ref(project_root, pkg_rel, head)
    if isinstance(head_symbols, str):
        return StructuralDiffResult(error=head_symbols)

    base_symbols = _extract_symbols_at_ref(project_root, pkg_rel, base)
    if isinstance(base_symbols, str):
        return StructuralDiffResult(error=base_symbols)

    return _compute_diff(base_symbols, head_symbols)

trace_flow(pkg, entry, *, max_depth=5, cross_module=False, detail='trace', callee_index=None, exclude_stdlib=True)

Trace execution flow from an entry point via BFS.

Follows the forward call graph from entry up to max_depth levels deep. Uses a visited set to handle circular calls.

Parameters:

Name Type Description Default
pkg PackageInfo

Analyzed package info.

required
entry str

Name of the entry point function to trace from.

required
max_depth int

Maximum BFS depth (default 5).

5
cross_module bool

If True, resolve imports and continue BFS into external modules on-demand.

False
detail str

Level of detail — "trace" (default) returns names and positions only; "source" enriches each step with the function's source code; "compact" produces a tree-formatted string. Must be one of VALID_DETAILS; raises :exc:ValueError otherwise.

'trace'
callee_index dict[tuple[str, str], list[CallSite]] | None

Optional pre-computed index from :func:build_callee_index. When provided, BFS uses O(1) dict lookups instead of scanning all modules.

None
exclude_stdlib bool

If True (default), skip callees whose name matches a stdlib module or Python builtin (e.g. len, isinstance). Set to False to include them.

True

Returns:

Type Description
list[FlowStep]

Tuple of (steps, truncated) where steps is a list of FlowStep

bool

objects ordered by depth then discovery, and truncated is True

tuple[list[FlowStep], bool]

when at least one frontier node at max_depth had unexpanded

tuple[list[FlowStep], bool]

children.

Example

steps, truncated = trace_flow(pkg, "main", max_depth=3) for s in steps: ... print(f"{' ' * s.depth}{s.name} ({s.module}:{s.line})")

Source code in packages/axm-ast/src/axm_ast/core/flows.py
Python
def trace_flow(  # noqa: PLR0913
    pkg: PackageInfo,
    entry: str,
    *,
    max_depth: int = 5,
    cross_module: bool = False,
    detail: str = "trace",
    callee_index: dict[tuple[str, str], list[CallSite]] | None = None,
    exclude_stdlib: bool = True,
) -> tuple[list[FlowStep], bool]:
    """Trace execution flow from an entry point via BFS.

    Follows the forward call graph from *entry* up to *max_depth*
    levels deep. Uses a visited set to handle circular calls.

    Args:
        pkg: Analyzed package info.
        entry: Name of the entry point function to trace from.
        max_depth: Maximum BFS depth (default 5).
        cross_module: If True, resolve imports and continue BFS
            into external modules on-demand.
        detail: Level of detail — ``"trace"`` (default) returns
            names and positions only; ``"source"`` enriches each
            step with the function's source code; ``"compact"``
            produces a tree-formatted string.  Must be one of
            ``VALID_DETAILS``; raises :exc:`ValueError` otherwise.
        callee_index: Optional pre-computed index from
            :func:`build_callee_index`.  When provided, BFS uses
            O(1) dict lookups instead of scanning all modules.
        exclude_stdlib: If True (default), skip callees whose name
            matches a stdlib module or Python builtin (e.g. ``len``,
            ``isinstance``).  Set to False to include them.

    Returns:
        Tuple of (steps, truncated) where *steps* is a list of FlowStep
        objects ordered by depth then discovery, and *truncated* is True
        when at least one frontier node at *max_depth* had unexpanded
        children.

    Example:
        >>> steps, truncated = trace_flow(pkg, "main", max_depth=3)
        >>> for s in steps:
        ...     print(f"{'  ' * s.depth}{s.name} ({s.module}:{s.line})")
    """
    if detail not in VALID_DETAILS:
        msg = f"Invalid detail={detail!r}; must be one of {sorted(VALID_DETAILS)}"
        raise ValueError(msg)

    t0 = time.perf_counter()

    # Find the entry point location
    entry_mod, entry_line = _find_symbol_location(pkg, entry)
    if entry_mod is None:
        msg = f"Symbol {entry!r} not found in package"
        raise ValueError(msg)

    # Pre-compute set of symbols defined in the package so we can
    # distinguish project callees from stdlib method calls (e.g.
    # logger.info → "info" is not in pkg_symbols → skip).
    pkg_symbols = _build_package_symbols(pkg) if exclude_stdlib else frozenset()

    steps: list[FlowStep] = []
    # Use (module, symbol) tuples to handle same-named symbols
    # in different modules.
    visited: set[tuple[str, str]] = {(entry_mod, entry)}
    # Queue: (symbol, depth, chain, source_pkg, source_module_dotted)
    queue: deque[tuple[str, int, list[str], PackageInfo, str]] = deque()
    queue.append((entry, 0, [entry], pkg, entry_mod))

    # Shared BFS context for cross-module resolution
    ctx = _CrossModuleContext(
        visited=visited,
        queue=queue,
        steps=steps,
        detail=detail,
        exclude_stdlib=exclude_stdlib,
        pkg_symbols=pkg_symbols,
    )

    # Add the entry point itself
    steps.append(
        FlowStep(
            name=entry,
            module=entry_mod,
            line=entry_line,
            depth=0,
            chain=[entry],
        )
    )

    truncated = False

    while queue:
        current, depth, current_chain, current_pkg, current_mod = queue.popleft()

        if depth >= max_depth:
            truncated = truncated or _check_frontier_truncated(
                current_mod,
                current,
                current_pkg,
                callee_index,
                ctx,
                exclude_stdlib=exclude_stdlib,
                pkg_symbols=pkg_symbols,
                visited=visited,
            )
            continue

        callees = _get_callees(current_mod, current, current_pkg, callee_index, ctx)
        _process_local_callees(
            callees=callees,
            exclude_stdlib=exclude_stdlib,
            pkg_symbols=pkg_symbols,
            visited=visited,
            current_chain=current_chain,
            depth=depth,
            steps=steps,
            queue=queue,
            current_pkg=current_pkg,
        )

        if cross_module:
            _resolve_cross_module_callees(
                callees,
                _ResolutionScope(
                    current_mod=current_mod,
                    current_pkg=current_pkg,
                    original_pkg=pkg,
                    depth=depth,
                    current_chain=current_chain,
                ),
                ctx,
            )

    if detail == "source":
        _enrich_steps_with_source(steps, pkg)

    elapsed = time.perf_counter() - t0
    logger.debug(
        "Traced %s in %.2fs (%d steps, depth=%d)",
        entry,
        elapsed,
        len(steps),
        max_depth,
    )

    return steps, truncated