Skip to content

✅ fresh

last synced 2026-09-29T21:12:19.681221+00:00 · coverage 100% (import-resolver)

Validation by Beadloom doc_sync — same source as sync-check.

Import Resolver ​

Import analysis and depends_on edge generation via tree-sitter-based source code parsing.

Source: src/beadloom/graph/import_resolver.py


Specification ​

Purpose ​

Extract import statements from source files using tree-sitter grammars, resolve each import to an architecture graph node, store the results in the code_imports table, and generate depends_on edges between graph nodes. This forms the foundation for automated dependency detection and architectural rule enforcement.

Supported Languages ​

LanguageFile ExtensionsImport Syntax HandledSkipped Imports
Python.pyimport X, from X import YRelative imports (from . import, from ..)
TypeScript/JavaScript.ts, .tsx, .js, .jsximport ... from 'path'Relative (./, ../), npm packages
Go.goimport "path", import (...) blocksStandard library (no / in path)
Rust.rsuse path::to::moduleBuilt-in crates (std, core, alloc), self, super

Constants ​

python
_RUST_BUILTIN_CRATES: frozenset[str] = frozenset({"std", "core", "alloc"})

_TS_ALIAS_MAP: dict[str, str] = {
    "@/": "src/",
    "~/": "src/",
}

Data Structures ​

ImportInfo ​

Frozen dataclass representing a single extracted import.

FieldTypeDescription
file_pathstrPath to the source file containing the import.
line_numberint1-based line number of the import statement.
import_pathstrRaw import path (e.g. "beadloom.auth.tokens").
resolved_ref_idstr | NoneResolved graph node ref_id, or None.

Import Extraction ​

python
def extract_imports(file_path: Path) -> list[ImportInfo]
  1. Detect language via file extension using get_lang_config(suffix). Return empty list if unsupported.
  2. Read file content as UTF-8. Return empty list on OSError, UnicodeDecodeError, or empty content.
  3. Parse content with tree_sitter.Parser using the detected language grammar.
  4. Dispatch to language-specific extractor based on extension.

AST traversal ​

Every extractor iterates _walk(root) — the whole tree in document order — not just the root's children. Extraction used to consider only a file's top-level statements, so an import inside a function, a class body, an if TYPE_CHECKING: guard or a try: block was invisible to the dependency graph. Those are exactly the places an import is put to defer cost or to break a cycle, so the graph was blind to the very edges the cycle and boundary rules exist to judge (BDL-UX #159). Measured on Beadloom when the walk was introduced: 460 nested imports, 231 of them first-party — about a third of all imports; the depends_on edge count went from 51 to 146, and six real boundary violations surfaced that had been hidden behind nested imports.

The statement types the extractors match never nest inside themselves, so a full walk cannot double-count a single statement.

Language-Specific Extractors ​

Python (_extract_python_imports):

  • Walks every import_statement / import_from_statement in the tree.
  • import_statement: extracts the dotted_name child as the import path.
  • import_from_statement: checks for relative_import child; if present, skips. Otherwise extracts the first dotted_name as the module path.

TypeScript/JavaScript (_extract_ts_imports):

  • Walks every import_statement node in the tree.
  • Extracts the string source via _get_ts_import_source (looks for string -> string_fragment children).
  • Skips imports starting with "." or ".." (relative).

Go (_extract_go_imports):

  • Walks import_declaration nodes.
  • Handles both single import_spec and grouped import_spec_list.
  • Extracts interpreted_string_literal_content from each spec.
  • Skips standard library packages (heuristic: no / in the path).

Rust (_extract_rust_imports):

  • Walks use_declaration nodes.
  • Extracts the path via _get_rust_use_path, handling scoped_identifier, identifier, scoped_use_list, and use_wildcard node types.
  • Determines root crate from the first :: segment.
  • Skips built-in crates (std, core, alloc) and relative imports (self, super).
  • Emits at most one ImportInfo per use_declaration.

Import Resolution ​

python
def resolve_import_to_node(
    import_path: str,
    file_path: Path,
    conn: sqlite3.Connection,
    scan_paths: list[str] | None = None,
    *,
    is_ts: bool = False,
) -> str | None
ParameterTypeDefaultDescription
import_pathstrrequiredRaw import path to resolve.
file_pathPathrequiredPath of the file containing the import.
connsqlite3.ConnectionrequiredDatabase connection.
scan_pathslist[str] | NoneNone (defaults to ["src", "lib", "app"])Source directories to search.
is_tsboolFalseWhether the import is from a TS/JS file.

Resolution strategies (tried in order):

Strategy 1 -- Code-symbols annotation lookup:

  1. Convert the import path to candidate file paths via _import_path_to_file_paths (replaces . with /, prepends each scan_path prefix, generates both .py and __init__.py variants).
  2. For each candidate, query code_symbols for annotations JSON.
  3. Parse the annotations and look for keys domain, service, or feature whose values match a nodes.ref_id (constructed as "{kind}:{value}").
  4. Return the first matching ref_id.

Strategy 2 -- Hierarchical source-prefix matching:

  1. For TypeScript/JavaScript (is_ts=True): normalize the import path via _normalize_ts_import. Returns None for npm packages (non-aliased, non-relative paths), terminating resolution.
  2. For other languages: convert the dotted path to a directory path (replace . with /).
  3. Call _find_node_by_source_prefix(dir_path, scan_paths, conn):
    • Prepend each scan_path prefix (plus bare path).
    • Split into path segments, walk from deepest to shallowest.
    • For each segment level, query nodes.source with and without trailing /.
    • Return the first matching ref_id.

Internal Resolution Helpers ​

FunctionDescription
_import_path_to_file_pathsConvert dotted import path to candidate file paths with scan_path prefixes. Generates .py and __init__.py variants.
_normalize_ts_importResolve @/ and ~/ aliases to src/. Returns None for npm packages.
_find_node_by_source_prefixWalk path hierarchy from deepest to shallowest, query nodes.source with and without trailing /.
_find_node_for_fileThe node that OWNS a file — delegates to infrastructure/repository.get_owning_ref_id (most specific source wins). Used by create_import_edges. Previously walked up from the file's PARENT directory, so a node whose source IS a file never owned that file and its imports were credited to the enclosing directory's node.
_walkPre-order traversal of the whole AST in document order; every extractor iterates it so imports below the top level are seen.
_part_of_ancestorsEach node mapped to the set of nodes it is transitively part_of, for the containment skip in create_import_edges. Reads the direct part_of edges and delegates the climb to graph/rules/layers.py::part_of_ancestors, which is the one ancestry walk in the codebase (BDL-070 A1).

Edge Generation ​

python
def create_import_edges(conn: sqlite3.Connection) -> int
  1. Query all distinct (file_path, resolved_ref_id) from code_imports where resolved_ref_id IS NOT NULL.
  2. For each row, determine the source node via _find_node_for_file(rel_path, conn) (file ownership).
  3. Skip if no source node is found or if source_ref_id == target_ref_id (self-reference).
  4. Skip containment in one direction only — an edge from a node to a node it is part_of (a container depending on its own part). A package façade re-exporting its children says nothing the part_of edge did not, and paired with a child's ordinary upward import it manufactures a node-level cycle with no module-level counterpart. The reverse is KEPT: a child reaching into shared code that lives in its container but belongs to no other node is a real dependency, and dropping it made such a node report "depends on nothing" — false, and worse than a coarse answer.
  5. Deduplicate (source, target) pairs via a seen set.
  6. Insert depends_on edge with INSERT OR IGNORE, stamping extra = {"derived": "imports"}. The marker is the provenance that lets an incremental refresh delete the derived set without touching a graph-declared edge; INSERT OR IGNORE means a pair also declared in YAML keeps the YAML row and stays unmarked.
  7. Commit and return the count of edges created.

Derived-Edge Refresh ​

python
def delete_derived_import_edges(conn: sqlite3.Connection) -> int
def refresh_import_edges(conn: sqlite3.Connection) -> int

The derived edge set is a pure function of code_imports, so a refresh is delete-then-recreate. Doing only the recreate half kept a dependency edge alive after its import was removed, for the cycle and layer rules to trip over.

Incremental Indexing ​

python
def reindex_file_imports(
    project_root: Path,
    conn: sqlite3.Connection,
    *,
    touched: Sequence[str],
    removed: Sequence[str],
) -> int

Deletes code_imports rows for the touched and removed paths, re-extracts the touched ones, then calls refresh_import_edges. This is what an incremental reindex calls; without it every import rule read an index frozen at the last FULL rebuild, so reindex && lint passed a real boundary break (BDL-UX #142).

Full Indexing Pipeline ​

python
def index_imports(project_root: Path, conn: sqlite3.Connection) -> int
  1. Resolve scan paths via resolve_scan_paths(project_root) from config.
  2. Collect source files via _collect_source_files(project_root), which uses resolve_scan_paths and supported_extensions() to enumerate files under each scan directory.
  3. For each file: a. Call extract_imports(file_path). Skip if empty. b. Read file content, compute SHA-256 hash, compute relative path. c. Determine is_ts flag from file extension (.ts, .tsx, .js, .jsx, .vue). d. For each ImportInfo, call resolve_import_to_node to resolve it. e. Upsert into code_imports with ON CONFLICT(file_path, line_number, import_path) DO UPDATE SET resolved_ref_id, file_hash.
  4. Commit.
  5. Call refresh_import_edges(conn) to regenerate depends_on edges.
  6. Return the total count of imports indexed.

Configuration ​

Scan paths are configurable via .beadloom/config.yml:

yaml
scan_paths:
  - src
  - lib
  - app

Default: ["src", "lib", "app"].


API ​

Public Functions ​

python
def extract_imports(file_path: Path) -> list[ImportInfo]: ...
def resolve_import_to_node(
    import_path: str,
    file_path: Path,
    conn: sqlite3.Connection,
    scan_paths: list[str] | None = None,
    *,
    is_ts: bool = False,
) -> str | None: ...
def create_import_edges(conn: sqlite3.Connection) -> int: ...
def delete_derived_import_edges(conn: sqlite3.Connection) -> int: ...
def refresh_import_edges(conn: sqlite3.Connection) -> int: ...
def index_imports(project_root: Path, conn: sqlite3.Connection) -> int: ...
def reindex_file_imports(
    project_root: Path,
    conn: sqlite3.Connection,
    *,
    touched: Sequence[str],
    removed: Sequence[str],
) -> int: ...

Public Classes ​

python
@dataclass(frozen=True)
class ImportInfo:
    file_path: str
    line_number: int
    import_path: str
    resolved_ref_id: str | None

Invariants ​

  • Self-references (source node == target node) never generate depends_on edges.
  • Each (source_ref_id, target_ref_id) pair generates at most one depends_on edge (deduplicated via seen set in create_import_edges and INSERT OR IGNORE).
  • Imports are upserted with ON CONFLICT(file_path, line_number, import_path) DO UPDATE, ensuring idempotent reindexing.
  • extract_imports returns an empty list (never raises) for unsupported languages, unreadable files, or empty files.
  • Resolution strategies are tried in strict order: annotation lookup first, then source-prefix matching.
  • _import_path_to_file_paths always includes the bare (no-prefix) variant as the last set of candidates.

Constraints ​

  • Requires tree-sitter grammar packages for each supported language (e.g. tree-sitter-python, tree-sitter-typescript). Returns empty list if the grammar is not installed.
  • Only processes files located under directories listed in scan_paths.
  • Relative imports are always skipped (language-specific detection):
    • Python: relative_import AST node presence.
    • TypeScript/JavaScript: path starts with "." or "..".
    • Go: no / in path (stdlib heuristic).
    • Rust: root identifier is self or super.
  • npm packages (non-aliased, non-relative TypeScript/JavaScript imports) are skipped by _normalize_ts_import returning None.
  • The code_symbols table must be populated for annotation-based resolution to work (Strategy 1).
  • The nodes table must be populated for source-prefix resolution to work (Strategy 2).
  • File content is read as UTF-8; files that raise UnicodeDecodeError are silently skipped.

Testing ​

Extraction Tests ​

  • Python imports. Parse a file with import foo, from bar import baz, and from . import relative. Assert the first two yield ImportInfo entries; the relative import is skipped.
  • TypeScript imports. Parse import X from '@/components/Button' and import Y from './local' and import Z from 'react'. Assert only the aliased import is extracted; relative and npm are skipped.
  • Go imports. Parse import ("fmt"; "github.com/org/pkg"). Assert only the non-stdlib import is extracted.
  • Rust imports. Parse use std::io; use my_crate::module; use super::sibling;. Assert only my_crate::module is extracted.
  • Unsupported extension. Pass a .txt file. Assert empty list returned.
  • Empty file. Assert empty list returned.
  • Unreadable file. Assert empty list returned without exception.

Resolution Tests ​

  • Annotation lookup hit. Insert a code_symbols row with annotations={"domain": "billing"} for a candidate file path. Insert a node with ref_id="domain:billing". Assert resolution returns "domain:billing".
  • Source-prefix matching. Insert a node with source="src/beadloom/auth/". Resolve import path beadloom.auth.tokens with scan_paths=["src"]. Assert the correct ref_id is returned.
  • TS alias resolution. Resolve @/shared/utils with is_ts=True. Assert it maps to src/shared/utils and matches the appropriate node.
  • TS npm package skip. Resolve react with is_ts=True. Assert None is returned.
  • No match. Resolve an import path with no corresponding annotation or node. Assert None.

Edge Generation Tests ​

  • Edges created. Insert two nodes and a resolved code_import. Call create_import_edges. Assert one depends_on edge is created.
  • Self-reference skipped. Import where source and target resolve to the same node. Assert zero edges.
  • Deduplication. Multiple imports from the same source to the same target. Assert exactly one edge.

Pipeline Tests ​

  • index_imports end-to-end. Set up a project with source files, nodes, and code_symbols. Call index_imports. Assert:
    • code_imports table is populated with correct file_path, line_number, import_path, resolved_ref_id.
    • depends_on edges are created in the edges table.
    • Return count matches the number of imports processed.
  • Idempotent reindex. Call index_imports twice. Assert the same results with no duplicates (upsert behavior).