Build a Codebase Graph Engine

Build a CLI that maps code structures and imports into an interactive graph.

Introduction

30 Second Summary

Finding a single function is easy when a project is small. Once the project grows, tracing what each file defines or depends on can mean jumping through folders for ages.

In this project, you will build a command-line engine that scans a codebase through the tree-sitter-language-pack catalog. It turns each detected file into a directed graph powered by NetworkX.

What You'll Build

You'll point the finished tool at a repository to open a draggable browser map of its files, code structures, imports, and directed relationships.

By the end of this project, you'll have:

  • A catalog-driven source scanner that identifies supported languages without a hand-written extension map.
  • A directed code graph where you can trace files to their classes, functions, methods, and imported modules through typed relationships.
  • Portable graph exports that let you inspect structured JSON or drag connected nodes around a self-contained browser view.
  • Secret Mission: Rewire uniquely matched imports from external placeholders to local files while leaving ambiguous matches untouched.

Are there any prerequisites?

You'll need a Windows machine with Python 3.12+ and Visual Studio Code already installed. You'll also need an internet connection for the first grammar download.

Step 1 creates your virtual environment and installs every pinned package.

Before We Start

Before the hands-on work begins, this checkpoint locks in the repository-level codebase graph you are building. It also captures the codebase you will scan after the sample plus the relationship you want to reveal.

Set Up the Project Environment

Your scanner depends on Tree-sitter to parse source files. The graph stage depends on NetworkX to model relationships.

A virtual environment keeps this project's packages separate from system Python. Pinned versions give every later step the same tested toolchain inside Visual Studio Code.

In this step, get ready to:
  • Create a Windows project folder in Visual Studio Code.
  • Build a supported virtual environment inside the folder.
  • Install the four pinned dependencies.
Create and open the project folder

Visual Studio Code treats an opened folder as a workspace. Its integrated terminal then starts from that folder.

  • Press the Windows key to open Windows Search.
  • Type Visual Studio Code.
  • Press Enter.
  • Select File from the top menu.
  • Select Open Folder.

The folder picker controls where the workspace lives. Keeping the project on your Desktop makes it easy to find during cleanup.

  • Select Desktop in the folder picker.
  • Select New Folder.
  • Enter codebase-graph-engine as the folder name.
  • Select Select Folder.
  • Select Yes, I trust the authors if the Workspace Trust dialog appears.

The Explorer sidebar now shows codebase-graph-engine. This confirms that your project folder is open as the workspace.

  • Select Terminal from the top menu.
  • Select New Terminal.
  • Create the .venv environment by running this command:
py -m venv .venv

What does this command do?

  • The py launcher selects the default installed Python interpreter.
  • The venv module creates an isolated environment inside .venv.
  • Later commands use the environment's Python executable directly.

Before you check, ask yourself which Python version this environment will report.

  • Print the environment's Python version by running this command:
.\.venv\Scripts\python.exe --version

What does this check prove?

The --version option prints the interpreter stored inside .venv. This verifies which Python release your pinned packages receive.

✔️ I see a supported version

You are using Python 3.12 or a later supported release. The value is different from 3.14.1.

That compatibility check is done: NetworkX 3.7 can install against this interpreter.

ⓧ I see the excluded patch

Deleting .venv may look drastic. This folder only contains the isolated environment you just created.

NetworkX 3.7 excludes Python 3.14.1. Recreate the environment with another installed Python 3.12+ release.

  • Remove the incompatible environment by running this command:
Remove-Item -Path ".venv" -Recurse -Force

What does this command remove?

PowerShell removes the .venv folder plus its contents. Your project folder remains in place.

  • List the installed Python runtimes by running this command:
py list

What should you look for?

The launcher lists the Python runtimes available on your computer. Choose a release numbered 3.12 or higher that is different from 3.14.1.

  • Record one compatible runtime tag as your compatible Python tag.
  • Recreate .venv with that runtime by running this command:
py -V:[[PYTHON_TAG="your compatible Python tag"]] -m venv .venv

Why select the runtime explicitly?

The -V: option tells the Windows launcher which installed runtime to use. The new .venv inherits that interpreter.

  • Check the recreated environment by running this command:
.\.venv\Scripts\python.exe --version

What should the new environment report?

The output now shows your selected compatible release. You can continue once the value is 3.12 or higher with 3.14.1 excluded.

Don't see the environment?

  • Confirm that the terminal path ends with codebase-graph-engine.
  • Run the environment creation command again after correcting the terminal location.

I need help creating the Windows virtual environment.

Pin and install the dependencies

The language catalog comes from tree-sitter-language-pack. PyVis later turns the directed graph into a browser view.

A requirements file records the exact package versions used by this project. Reinstalling from this file produces the same dependency set.

  • Select the New File icon in the Explorer sidebar.
  • Type requirements.txt.
  • Press Enter.
  • Add the four dependency pins by pasting this content:
tree-sitter-language-pack==1.22.1
tree-sitter==0.26.0
networkx==3.7
pyvis==0.3.2

Why these exact pins?

  • The tree-sitter-language-pack==1.22.1 package supplies the language catalog plus normalized code intelligence.
  • The tree-sitter==0.26.0 package supplies the Python parser bindings.
  • The networkx==3.7 package supplies the directed graph model.
  • The pyvis==0.3.2 package supplies the interactive HTML visualization.
  • Save requirements.txt.
  • Confirm that the editor shows four dependency lines.

Saved the wrong file name?

  • Confirm that the file is named exactly requirements.txt.
  • Remove any extra file extension that Windows added.

Help me check my requirements file.

✔️ Awesome, I've got everything!

Your dependency file is ready. Double-check that requirements.txt is saved before installing the packages.

ⓧ I'd like to double check the full code

Compare your complete requirements.txt file with this reference.

tree-sitter-language-pack==1.22.1
tree-sitter==0.26.0
networkx==3.7
pyvis==0.3.2

What should match?

Each package name must match this reference. Each version pin must also match this reference.

  • Install the pinned dependencies into .venv by running this command:
.\.venv\Scripts\python.exe -m pip install -r requirements.txt

What does this command do?

  • The command invokes pip through the Python executable inside .venv.
  • The -r option reads every package pin from requirements.txt.
  • The packages stay isolated from your system Python installation.

The terminal shows download plus installation progress. The PowerShell prompt returns when all four pinned dependencies are installed.

Dependency installation failed?

  • Confirm that the terminal is still inside your project folder.
  • Confirm that requirements.txt contains the four exact pins from the reference tab.
  • Confirm that the Python version check did not report 3.14.1.

Help me troubleshoot the dependency installation.

Verify the interpreter and imports

A completed installation only matters when the environment can load every dependency. This final check tests the interpreter plus all four imports from the same .venv.

Before you run the final check, ask yourself whether all four imports will succeed.

  • Print the environment version plus the import result by running these commands:
.\.venv\Scripts\python.exe --version
.\.venv\Scripts\python.exe -c "import tree_sitter, tree_sitter_language_pack, networkx, pyvis; print('ready')"

What does this verification test?

  • The first command confirms that .venv still uses the supported interpreter.
  • The second command imports all four installed modules.
  • Python prints ready only after every import succeeds.

You should see a supported Python version followed by ready. That is the environment locked down for your first repository scan.

Don't see ready?

  • Confirm that the command starts with the Python executable inside .venv.
  • Find the package named in the import error.
  • Repeat the requirements installation after correcting any failed package.

Help me fix the failed import check.

Your isolated Python toolchain is ready. Next, you will use the language catalog to discover recognized source files in the sample repository.

Discover Source Files

Your isolated Python environment is ready. The next challenge is deciding which files in a repository belong in the graph.

A manual extension table would limit the scanner to languages you list yourself. This step uses the tree-sitter-language-pack catalog to discover recognized files before a deliberately simple NetworkX graph exposes the relationships still missing.

In this step, get ready to:
  • Create a sample repository with a class, functions, and a local import.
  • Detect recognized source files through the language catalog.
  • Run a file-only directed graph with two nodes and zero edges.
Create the sample repository

The fixture gives the scanner a small repository with predictable contents. Its local import also creates a dependency that the file-only graph cannot represent yet.

  • Use the new-folder control in Visual Studio Code's left file sidebar to create sample_repo inside the open project folder.
  • Select the sample_repo folder in the file sidebar.
  • Use the new-file control to create app.py inside sample_repo.
  • Add the sample entry point to sample_repo/app.py by replacing its contents with this code:
from helpers import Greeter


def run() -> None:
    greeter = Greeter()
    print(greeter.greet("Code Graph"))


if __name__ == "__main__":
    run()

What does this sample do?

  • The import creates a dependency on the local helpers module.
  • The run() function creates a Greeter instance.
  • The final guard runs the function when this file is executed directly.
  • Save sample_repo/app.py.
  • Confirm that app.py remains listed beneath sample_repo in the file sidebar.

Is app.py in the wrong folder?

Check that app.py is indented beneath sample_repo in the file sidebar. Move the file into that folder if it appears beside it.

Ask for help if the file layout still looks different: help me place app.py inside sample_repo in Visual Studio Code

  • Select the sample_repo folder in the file sidebar.
  • Use the new-file control to create helpers.py inside sample_repo.
  • Add the imported function and class to sample_repo/helpers.py by replacing its contents with this code:
def format_name(name: str) -> str:
    return f"Hello, {name}!"


class Greeter:
    def greet(self, name: str) -> str:
        return format_name(name)

What does this sample define?

  • The format_name() function produces the greeting text.
  • The Greeter class contains the nested greet() method.
  • These nested structures give the extractor meaningful symbols to find in the next step.
  • Save sample_repo/helpers.py.
  • Confirm that app.py appears beneath sample_repo in the file sidebar.
  • Confirm that helpers.py appears beneath sample_repo in the file sidebar.

Does the import look unresolved?

Confirm that both Python files sit directly inside the same sample_repo folder. A nested copy of helpers.py prevents the local import from matching this fixture.

Ask for help if the folder structure is correct but the editor still reports a problem: help me diagnose the local helpers import

Build catalog-driven discovery

The language catalog can identify a file from its path. A shebang fallback also lets the scanner recognize supported extensionless scripts from their first line.

  • Select the open project folder in Visual Studio Code's file sidebar.
  • Use the new-file control to create scanner.py inside that folder.
  • Add the path utilities and catalog detection imports to scanner.py with this code:
from pathlib import Path

from tree_sitter_language_pack import (
    detect_language_from_content,
    detect_language_from_path,
)

Why use two detectors?

The detect_language_from_path() function handles files whose names identify a catalog language. The detect_language_from_content() fallback checks the shebang in an unknown file's first line.

  • Save scanner.py.
  • Check the editor's diagnostics area for unresolved imports.

You should see no unresolved-import warning for tree_sitter_language_pack because the pinned package is installed in your verified environment.

Is the import unresolved?

Confirm that Visual Studio Code is using the interpreter inside .venv. The editor can report a missing package when it analyzes the file with a different interpreter.

Ask for help if the installed package remains unresolved: help me select the project virtual environment

  • Add the ignored directory names beneath the imports in scanner.py with this code:
IGNORED_DIRECTORIES = {
    ".git",
    ".idea",
    ".mypy_cache",
    ".pytest_cache",
    ".venv",
    ".vscode",
    "__pycache__",
    "build",
    "codegraph-out",
    "dist",
    "node_modules",
    "target",
    "vendor",
}

Why skip these directories?

These directories commonly contain project metadata, generated files, dependencies, caches, or graph output. Skipping them keeps discovery focused on the repository's source files.

  • Save scanner.py.
  • Confirm that the ignored names appear together beneath IGNORED_DIRECTORIES in the editor.

Is the set highlighted as incomplete?

Check that the block starts with an opening curly brace. Check that it ends with a closing curly brace.

Ask for help if the editor still highlights the block: help me fix the IGNORED_DIRECTORIES set

  • Add the directory-filter helper beneath IGNORED_DIRECTORIES in scanner.py with this code:
def _is_ignored(path: Path, root: Path) -> bool:
    relative_parts = path.relative_to(root).parts
    return any(part in IGNORED_DIRECTORIES for part in relative_parts)

What does the filter check?

The helper converts each candidate into parts relative to the scanned directory. It rejects the candidate when any part matches an ignored directory name.

  • Save scanner.py.
  • Confirm that the editor recognizes _is_ignored() as a function beneath the ignored directory set.

Is the helper nested inside the set?

Confirm that the closing curly brace for IGNORED_DIRECTORIES appears before _is_ignored(). The function definition must begin at the left edge of the file.

Ask for help if the function remains nested: help me fix the scanner.py indentation

  • Add the language detection helper beneath _is_ignored() in scanner.py with this code:
def _detect_language(path: Path) -> str | None:
    language = detect_language_from_path(str(path))
    if language is not None:
        return language

    try:
        with path.open("r", encoding="utf-8", errors="ignore") as source_file:
            first_line = source_file.readline()
    except OSError:
        return None

    return detect_language_from_content(first_line)

How does the fallback work?

  • Path detection returns immediately when the catalog recognizes the filename.
  • The fallback reads only the first line when path detection returns no language.
  • An unreadable file returns no language instead of stopping the complete scan.
  • Save scanner.py.
  • Confirm that the editor recognizes _detect_language() as a second function.

Is the try block incomplete?

Check that the file-reading lines are indented beneath try. Check that the fallback return aligns with the try statement.

Ask for help if the editor still reports a syntax problem: help me inspect the detection helper

  • Add the repository collection function beneath _detect_language() in scanner.py with this code:
def collect_source_files(root: Path) -> list[tuple[Path, str]]:
    root = root.resolve()
    discovered: list[tuple[Path, str]] = []

    for path in root.rglob("*"):
        if not path.is_file() or _is_ignored(path, root):
            continue

        language = _detect_language(path)
        if language is not None:
            discovered.append((path, language))

    return sorted(discovered, key=lambda item: item[0].as_posix().lower())

What does collection produce?

  • The recursive scan considers every file beneath the chosen repository.
  • Ignored paths leave the scan before language detection runs.
  • Recognized files become path-language pairs sorted by normalized path.
  • Save scanner.py.
  • Confirm that the file now contains _is_ignored(), _detect_language(), and collect_source_files().

Is a discovered file missing later?

Check that root.rglob("*") remains inside the collection function. Confirm that the append call sits inside the language check.

Ask for help if the scan still omits a recognized file: help me debug collect_source_files

Run the file-only graph

A file-only graph proves that catalog discovery works before structural extraction exists. Its zero edges make the current limitation visible.

  • Use the new-file control in Visual Studio Code's file sidebar to create main.py inside the open project folder.
  • Add the temporary graph prototype to main.py by replacing its contents with this code:
import argparse
from pathlib import Path

import networkx as nx

from scanner import collect_source_files


# Read the repository directory supplied on the command line.
parser = argparse.ArgumentParser()
parser.add_argument("root", type=Path)
args = parser.parse_args()
root = args.root.resolve()

# Represent each discovered source file as one graph node.
discovered = collect_source_files(root)
graph = nx.DiGraph()

for path, language in discovered:
    relative_path = path.resolve().relative_to(root).as_posix()
    graph.add_node(f"file:{relative_path}", language=language)
    print(f"{relative_path} [{language}]")

print(f"Graph nodes: {graph.number_of_nodes()}")
print(f"Graph edges: {graph.number_of_edges()}")

What does the prototype prove?

  • The command-line argument identifies the repository to scan.
  • Each catalog-recognized file becomes one node in an nx.DiGraph().
  • The printed counts reveal that discovery has produced files without relationships.
  • Save main.py.
  • Return to the terminal from the previous step.

Before you run the prototype, do you expect its files to be connected yet?

  • Scan the sample repository by running this command:
.\.venv\Scripts\python.exe main.py sample_repo

What does this check prove?

The project interpreter runs the prototype against sample_repo. The resulting output tests catalog detection and graph construction together.

You should see app.py [python] followed by helpers.py [python].

You should also see Graph nodes: 2 followed by Graph edges: 0. The disconnected result is intentional because structural extraction arrives in the next step.

Do you see the wrong counts?

Confirm that the command targets sample_repo. Check that both Python files sit directly inside that folder.

If extra nodes appear, confirm that collect_source_files() receives the sample repository as its root. This keeps the project environment outside the scanned path.

Ask for help with the observed output: help me debug the file-only graph counts

✔️ Awesome, I've got everything!

Your catalog scanner now finds both sample files. Your first directed graph contains two file nodes with zero edges.

ⓧ I'd like to double check the full code

Compare each file below with your saved project files.

tree-sitter-language-pack==1.22.1
tree-sitter==0.26.0
networkx==3.7
pyvis==0.3.2
from helpers import Greeter


def run() -> None:
    greeter = Greeter()
    print(greeter.greet("Code Graph"))


if __name__ == "__main__":
    run()
def format_name(name: str) -> str:
    return f"Hello, {name}!"


class Greeter:
    def greet(self, name: str) -> str:
        return format_name(name)
from pathlib import Path

from tree_sitter_language_pack import (
    detect_language_from_content,
    detect_language_from_path,
)

IGNORED_DIRECTORIES = {
    ".git",
    ".idea",
    ".mypy_cache",
    ".pytest_cache",
    ".venv",
    ".vscode",
    "__pycache__",
    "build",
    "codegraph-out",
    "dist",
    "node_modules",
    "target",
    "vendor",
}


def _is_ignored(path: Path, root: Path) -> bool:
    relative_parts = path.relative_to(root).parts
    return any(part in IGNORED_DIRECTORIES for part in relative_parts)


def _detect_language(path: Path) -> str | None:
    language = detect_language_from_path(str(path))
    if language is not None:
        return language

    try:
        with path.open("r", encoding="utf-8", errors="ignore") as source_file:
            first_line = source_file.readline()
    except OSError:
        return None

    return detect_language_from_content(first_line)


def collect_source_files(root: Path) -> list[tuple[Path, str]]:
    root = root.resolve()
    discovered: list[tuple[Path, str]] = []

    for path in root.rglob("*"):
        if not path.is_file() or _is_ignored(path, root):
            continue

        language = _detect_language(path)
        if language is not None:
            discovered.append((path, language))

    return sorted(discovered, key=lambda item: item[0].as_posix().lower())
import argparse
from pathlib import Path

import networkx as nx

from scanner import collect_source_files


# Read the repository directory supplied on the command line.
parser = argparse.ArgumentParser()
parser.add_argument("root", type=Path)
args = parser.parse_args()
root = args.root.resolve()

# Represent each discovered source file as one graph node.
discovered = collect_source_files(root)
graph = nx.DiGraph()

for path, language in discovered:
    relative_path = path.resolve().relative_to(root).as_posix()
    graph.add_node(f"file:{relative_path}", language=language)
    print(f"{relative_path} [{language}]")

print(f"Graph nodes: {graph.number_of_nodes()}")
print(f"Graph edges: {graph.number_of_edges()}")

That is the first visible slice of your graph engine working. Next, you will extract classes, functions, methods, and imports so those isolated file nodes gain meaningful relationships.

Extract Structures and Imports

Your catalog-driven scanner can already find Python files. Its file-only NetworkX graph cannot reveal what those files define or import.

This step adds normalized extraction records from Tree-sitter and tree-sitter-language-pack. Stable IDs connect files to nested structures through typed relationships.

In this step, get ready to:
  • Parse each discovered file with its catalog-selected grammar.
  • Flatten nested code structures into stable repository-relative records.
  • Convert imports into module nodes with directed relationships.
Normalize extraction metadata

Catalog results can represent structure kinds in several forms. Small normalization helpers turn those values into consistent lowercase text plus one-based source lines.

  • Create an empty extractor.py file from the Visual Studio Code file sidebar.
  • Add the imports plus the kind and span helpers by pasting this code:
from html import escape
from pathlib import Path
from typing import Any

from tree_sitter import Parser
from tree_sitter_language_pack import ProcessConfig, get_language, process


def _kind_text(kind: Any) -> str:
    if isinstance(kind, str):
        return kind.lower()

    name = getattr(kind, "name", None)
    if name:
        return str(name).lower()

    return str(kind).rsplit(".", maxsplit=1)[-1].lower()


def _span_line(span: Any) -> int:
    return span.start_line + 1 if span is not None else 1

What do these helpers normalize?

  • The _kind_text() helper produces a predictable lowercase kind for each structure record.
  • The _span_line() helper converts zero-indexed source spans into line numbers that start at 1.
  • The escape() import prepares dynamic text for safe HTML hover details later.
  • Save extractor.py.
  • Confirm the editor recognizes _kind_text() plus _span_line() as functions in the file.

Seeing a syntax problem?

Check the indentation inside both helpers. Make sure the quotes around "." remain paired.

Ask for help with the helper functions.

  • Add the symbol color helper below _span_line() by pasting this code:
def _symbol_color(kind: str) -> str:
    return {
        "class": "#f59e0b",
        "interface": "#f59e0b",
        "struct": "#f59e0b",
        "function": "#22c55e",
        "method": "#14b8a6",
        "module": "#8b5cf6",
        "namespace": "#8b5cf6",
        "enum": "#ec4899",
        "trait": "#ec4899",
    }.get(kind, "#94a3b8")

What does the color map do?

The mapping assigns consistent colors to common structure kinds. Unknown kinds receive the neutral fallback color so every extracted symbol remains visible.

  • Save extractor.py.
  • Confirm the editor now recognizes _symbol_color() as a third helper function.

Is the color helper underlined?

Check that every dictionary key has a matching value. Keep the final .get() call aligned with the closing brace.

Ask for help with the color mapping.

Why normalized records instead of every syntax node?

A concrete syntax tree contains every grammar detail in a source file. A repository graph benefits from selected structures such as classes plus functions.

Normalized records keep the graph focused on code exploration. The raw syntax root still contributes parse metadata without flooding the graph.

Parse files and flatten nested structures

The extractor uses the detected language name to load the matching grammar. It combines the upstream parser's root metadata with the package's normalized structure and import records.

  • Add the start of extract_file() below _symbol_color() by pasting this code:
def extract_file(path: Path, root: Path, language_name: str) -> dict[str, list[dict[str, Any]]]:
    source = path.read_text(encoding="utf-8", errors="replace")
    relative_path = path.resolve().relative_to(root.resolve()).as_posix()

    language = get_language(language_name)
    syntax_tree = Parser(language).parse(source.encode("utf-8"))
    root_node = syntax_tree.root_node

    result = process(
        source,
        ProcessConfig(
            language=language_name,
            structure=True,
            imports=True,
            exports=False,
            diagnostics=True,
        ),
    )

How does parsing work here?

  • The relative_path value gives every record a portable path within the scanned repository.
  • The get_language() call loads the grammar selected by the scanner.
  • The Parser produces root syntax metadata from the source bytes.
  • The ProcessConfig request enables structures plus imports plus diagnostics.
  • Save extractor.py.
  • Confirm the editor reports no syntax problem inside extract_file().

Are the parser imports unresolved?

Confirm Visual Studio Code is still using the verified .venv interpreter. The pinned packages already provide every import used here.

Ask for help with the selected interpreter.

  • Append the file record setup inside extract_file() by pasting this code:
    file_id = f"file:{relative_path}"
    error_count = result.metrics.error_count if result.metrics is not None else 0
    nodes: list[dict[str, Any]] = [
        {
            "id": file_id,
            "label": Path(relative_path).name,
            "kind": "file",
            "path": relative_path,
            "language": language_name,
            "root_type": root_node.type,
            "has_errors": root_node.has_error,
            "error_count": error_count,
            "title": (
                f"<b>{escape(relative_path)}</b><br>"
                f"language: {escape(language_name)}<br>"
                f"root: {escape(root_node.type)}<br>"
                f"parse errors: {error_count}"
            ),
            "color": "#3b82f6" if error_count == 0 else "#ef4444",
            "shape": "box",
            "size": 25,
        }
    ]
    edges: list[dict[str, Any]] = []

    return {"nodes": nodes, "edges": edges}

What does the file record contain?

The file ID begins with file: plus the repository-relative path. The record also carries the detected language plus syntax-root metadata plus the parser's error count.

The label plus color plus shape plus title prepare the same record for the browser visualization. Parse errors turn the file node red.

  • Save extractor.py.
  • Confirm the final line of extract_file() returns the nodes and edges lists.

Is the return line outside the function?

Keep the file record plus the return line indented by four spaces. Keep every dictionary field nested inside the nodes list.

Ask for help with the file record indentation.

  • Find the edges: list[dict[str, Any]] = [] line inside extract_file().
  • Insert the structure-node helper below that line by pasting this code:
    def add_structure(item: Any, parent_id: str) -> None:
        kind = _kind_text(item.kind)
        line = _span_line(item.span)
        name = item.name or f"anonymous_{line}"
        symbol_id = f"symbol:{relative_path}:{kind}:{line}:{name}"
        signature = item.signature or name

        nodes.append(
            {
                "id": symbol_id,
                "label": name,
                "kind": kind,
                "path": relative_path,
                "language": language_name,
                "line": line,
                "signature": signature,
                "title": (
                    f"<b>{escape(name)}</b><br>"
                    f"kind: {escape(kind)}<br>"
                    f"file: {escape(relative_path)}<br>"
                    f"line: {line}<br>"
                    f"signature: {escape(signature)}"
                ),
                "color": _symbol_color(kind),
                "shape": "dot",
                "size": 16,
            }
        )

How are symbol IDs kept stable?

Each symbol ID combines the repository-relative path plus normalized kind plus one-based line plus name. A structure such as Greeter therefore remains tied to its source location.

Anonymous structures receive a line-based fallback name. Every symbol also carries its signature plus visualization attributes.

  • Save extractor.py.
  • Confirm add_structure() appears nested inside extract_file().

Is add_structure at the wrong level?

Indent the add_structure() definition by four spaces. Indent its body by another four spaces.

Ask for help with the nested helper.

  • Append the containment edge plus recursion inside add_structure() by pasting this code:
        edges.append(
            {
                "source": parent_id,
                "target": symbol_id,
                "relation": "contains",
                "confidence": "EXTRACTED",
                "label": "contains",
                "title": "contains",
                "arrows": "to",
            }
        )

        for child in item.children:
            add_structure(child, symbol_id)

    for structure_item in result.structure:
        add_structure(structure_item, file_id)

How does the hierarchy become a graph?

Each structure receives a directed contains edge from its parent. The EXTRACTED confidence records that the parser produced this relationship.

Recursion walks every StructureItem.children collection. A method therefore connects to its class while a top-level function connects to its file.

  • Move the existing return line below the structure loop.
  • Save extractor.py.

Before you run the preview, which nested symbol do you expect to connect beneath Greeter?

  • Preview the extracted structures by running this command:
.\.venv\Scripts\python.exe main.py sample_repo

What should you see?

You should see extraction records for Greeter plus greet plus format_name plus run. The nested method record should use the Greeter symbol as its parent.

Are structures missing?

Check that the structure loop sits inside extract_file() after the nested helper. Confirm the return line comes after that loop.

Ask for help with the extraction preview.

Convert imports into graph relationships

An import links a source file to a module it depends on. The extractor represents that module as an explicit node so the graph can preserve unresolved dependencies.

  • Find the structure loop immediately above the return line in extractor.py.
  • Add the imported-module node loop below it by pasting this code:
    for import_info in result.imports:
        imported_source = import_info.source or "unknown"
        import_id = f"external:{language_name}:{imported_source}"
        line = _span_line(import_info.span)

        nodes.append(
            {
                "id": import_id,
                "label": imported_source,
                "kind": "external_module",
                "module": imported_source,
                "language": language_name,
                "title": (
                    f"<b>{escape(imported_source)}</b><br>"
                    f"imported by: {escape(relative_path)}<br>"
                    f"line: {line}"
                ),
                "color": "#64748b",
                "shape": "diamond",
                "size": 14,
            }
        )

How are imported modules represented?

The imported source becomes part of an external: ID alongside the detected language. The record also stores the source line plus module name.

A diamond shape separates imported-module placeholders from file boxes plus structure dots. This distinction makes unresolved dependencies visible.

  • Move the existing return line below the import loop.
  • Save extractor.py.

Before you run the preview, do you expect the imported source to use the name helpers or Greeter?

  • Preview the imported-module record by running this command:
.\.venv\Scripts\python.exe main.py sample_repo

What should you see now?

You should see an imported-module record whose source is helpers. Its stable ID should be external:python:helpers.

Is the helpers module missing?

Confirm the import loop reads from result.imports. Check that sample_repo/app.py still begins with its local import.

Ask for help with the import record.

  • Add the import edge inside the import loop by pasting this code:
        edges.append(
            {
                "source": file_id,
                "target": import_id,
                "relation": "imports",
                "confidence": "EXTRACTED",
                "line": line,
                "label": "imports",
                "title": f"imports at line {line}",
                "arrows": "to",
            }
        )

What does the import edge record?

The edge points from the importing file to the imported-module node. Its imports relation plus EXTRACTED confidence preserve the relationship's meaning plus origin.

The source line appears in the edge metadata. The arrow setting preserves direction in the later browser view.

  • Keep the return line after the completed import loop.
  • Save extractor.py.

Before you run the final check, how many named structures from the sample do you expect in the preview?

  • Verify the completed extractor by running this command:
.\.venv\Scripts\python.exe main.py sample_repo

What should the final preview contain?

You should see Greeter plus greet plus format_name plus run. You should also see an import whose source is helpers.

The preview now contains file nodes plus structure nodes plus an imported-module node. Directed contains and imports records replace the disconnected file-only result.

Does the final preview fail?

Check that the final return line remains inside extract_file() after both loops. Confirm every nested dictionary uses matching braces plus matching parentheses.

Ask for help with the completed extractor.

✔️ Awesome, I've got everything!

Great work. Save extractor.py before moving on.

ⓧ I'd like to double check the full code

Compare your complete extractor.py file with this reference.

from html import escape
from pathlib import Path
from typing import Any

from tree_sitter import Parser
from tree_sitter_language_pack import ProcessConfig, get_language, process


def _kind_text(kind: Any) -> str:
    if isinstance(kind, str):
        return kind.lower()

    name = getattr(kind, "name", None)
    if name:
        return str(name).lower()

    return str(kind).rsplit(".", maxsplit=1)[-1].lower()


def _span_line(span: Any) -> int:
    return span.start_line + 1 if span is not None else 1


def _symbol_color(kind: str) -> str:
    return {
        "class": "#f59e0b",
        "interface": "#f59e0b",
        "struct": "#f59e0b",
        "function": "#22c55e",
        "method": "#14b8a6",
        "module": "#8b5cf6",
        "namespace": "#8b5cf6",
        "enum": "#ec4899",
        "trait": "#ec4899",
    }.get(kind, "#94a3b8")


def extract_file(path: Path, root: Path, language_name: str) -> dict[str, list[dict[str, Any]]]:
    source = path.read_text(encoding="utf-8", errors="replace")
    relative_path = path.resolve().relative_to(root.resolve()).as_posix()

    language = get_language(language_name)
    syntax_tree = Parser(language).parse(source.encode("utf-8"))
    root_node = syntax_tree.root_node

    result = process(
        source,
        ProcessConfig(
            language=language_name,
            structure=True,
            imports=True,
            exports=False,
            diagnostics=True,
        ),
    )

    file_id = f"file:{relative_path}"
    error_count = result.metrics.error_count if result.metrics is not None else 0
    nodes: list[dict[str, Any]] = [
        {
            "id": file_id,
            "label": Path(relative_path).name,
            "kind": "file",
            "path": relative_path,
            "language": language_name,
            "root_type": root_node.type,
            "has_errors": root_node.has_error,
            "error_count": error_count,
            "title": (
                f"<b>{escape(relative_path)}</b><br>"
                f"language: {escape(language_name)}<br>"
                f"root: {escape(root_node.type)}<br>"
                f"parse errors: {error_count}"
            ),
            "color": "#3b82f6" if error_count == 0 else "#ef4444",
            "shape": "box",
            "size": 25,
        }
    ]
    edges: list[dict[str, Any]] = []

    def add_structure(item: Any, parent_id: str) -> None:
        kind = _kind_text(item.kind)
        line = _span_line(item.span)
        name = item.name or f"anonymous_{line}"
        symbol_id = f"symbol:{relative_path}:{kind}:{line}:{name}"
        signature = item.signature or name

        nodes.append(
            {
                "id": symbol_id,
                "label": name,
                "kind": kind,
                "path": relative_path,
                "language": language_name,
                "line": line,
                "signature": signature,
                "title": (
                    f"<b>{escape(name)}</b><br>"
                    f"kind: {escape(kind)}<br>"
                    f"file: {escape(relative_path)}<br>"
                    f"line: {line}<br>"
                    f"signature: {escape(signature)}"
                ),
                "color": _symbol_color(kind),
                "shape": "dot",
                "size": 16,
            }
        )
        edges.append(
            {
                "source": parent_id,
                "target": symbol_id,
                "relation": "contains",
                "confidence": "EXTRACTED",
                "label": "contains",
                "title": "contains",
                "arrows": "to",
            }
        )

        for child in item.children:
            add_structure(child, symbol_id)

    for structure_item in result.structure:
        add_structure(structure_item, file_id)

    for import_info in result.imports:
        imported_source = import_info.source or "unknown"
        import_id = f"external:{language_name}:{imported_source}"
        line = _span_line(import_info.span)

        nodes.append(
            {
                "id": import_id,
                "label": imported_source,
                "kind": "external_module",
                "module": imported_source,
                "language": language_name,
                "title": (
                    f"<b>{escape(imported_source)}</b><br>"
                    f"imported by: {escape(relative_path)}<br>"
                    f"line: {line}"
                ),
                "color": "#64748b",
                "shape": "diamond",
                "size": 14,
            }
        )
        edges.append(
            {
                "source": file_id,
                "target": import_id,
                "relation": "imports",
                "confidence": "EXTRACTED",
                "line": line,
                "label": "imports",
                "title": f"imports at line {line}",
                "arrows": "to",
            }
        )

    return {"nodes": nodes, "edges": edges}

What should match?

The file should contain three normalization helpers plus one extract_file() function. That function should finish with the structure loop plus import loop plus normalized return value.

You have closed the semantic gap in the file-only graph. Next, you will merge these normalized records into one directed code graph plus a portable JSON document.

Assemble and Export the Graph

Your extractor now turns each source file into normalized extraction records. Those dictionaries stay isolated. They cannot answer repository-wide graph questions.

This step merges every record into a shared directed graph using NetworkX. It then saves a portable JSON document that other tools can inspect.

In this step, get ready to:
  • Merge file extraction records into one directed NetworkX graph.
  • Serialize the graph into a portable JSON document.
  • Connect the CLI to the scan-to-export pipeline.
Merge extraction records into a directed graph

A directed graph gives each relationship an ordered source-to-target direction. Attributes preserve the extraction details that the interactive view uses later.

  • In the Visual Studio Code window from earlier, select the project folder in the file sidebar.
  • Use the new-file control in the file sidebar.
  • Name the new file builder.py.
  • Create the graph-building function by copying this code into builder.py:
import json
from pathlib import Path
from typing import Any

import networkx as nx


def build_graph(extractions: list[dict[str, list[dict[str, Any]]]]) -> nx.DiGraph:
    graph = nx.DiGraph()

    for extraction in extractions:
        for node in extraction.get("nodes", []):
            node_id = str(node["id"])
            attributes = {key: value for key, value in node.items() if key != "id"}
            graph.add_node(node_id, **attributes)

        for edge in extraction.get("edges", []):
            source = str(edge["source"])
            target = str(edge["target"])
            attributes = {
                key: value
                for key, value in edge.items()
                if key not in {"source", "target"}
            }
            graph.add_edge(source, target, **attributes)

    return graph

What Does This Code Do?

  • The build_graph() function creates one nx.DiGraph() for every extraction record.
  • Removing id from the attribute mapping keeps each identifier in NetworkX's node key.
  • Removing source plus target from the edge attributes keeps endpoints separate from relationship metadata.
  • Save builder.py.
  • Confirm builder.py appears beside extractor.py in the project file list.

Seeing a Syntax Warning?

Check the indentation beneath both loops. Make sure the attribute comprehensions remain inside their matching node or edge loop.

Ask for help with a builder.py syntax warning.

Export the graph as portable JSON

A stable export schema keeps the saved graph independent from any visualization library. The document records its direction before listing its nodes plus edges.

  • In builder.py, place the cursor below return graph.
  • Add the JSON writer by copying this code:
def write_json(graph: nx.DiGraph, output_path: Path) -> None:
    payload = {
        "directed": True,
        "nodes": [
            {"id": node_id, **attributes}
            for node_id, attributes in graph.nodes(data=True)
        ],
        "edges": [
            {"source": source, "target": target, **attributes}
            for source, target, attributes in graph.edges(data=True)
        ],
    }
    output_path.write_text(json.dumps(payload, indent=2), encoding="utf-8")

How Is the Export Structured?

  • The directed field tells graph consumers that edge direction matters.
  • Each node restores its id beside the attributes stored by NetworkX.
  • Each edge restores its source plus target beside attributes such as relation.
  • The indentation makes graph.json readable in a text editor.
  • Save builder.py.
  • Confirm write_json() appears below build_graph() without a red syntax underline.

Is the JSON Writer Underlined?

Check that write_json() starts at the left edge of the file. An extra indent would place it inside build_graph().

Ask for help with the write_json() indentation.

✔️ Awesome, I've got everything!

Great. Your builder now merges extraction records into a directed graph and writes its portable JSON representation.

ⓧ I'd like to double check the full code

Compare your complete builder.py with this reference.

import json
from pathlib import Path
from typing import Any

import networkx as nx


def build_graph(extractions: list[dict[str, list[dict[str, Any]]]]) -> nx.DiGraph:
    graph = nx.DiGraph()

    for extraction in extractions:
        for node in extraction.get("nodes", []):
            node_id = str(node["id"])
            attributes = {key: value for key, value in node.items() if key != "id"}
            graph.add_node(node_id, **attributes)

        for edge in extraction.get("edges", []):
            source = str(edge["source"])
            target = str(edge["target"])
            attributes = {
                key: value
                for key, value in edge.items()
                if key not in {"source", "target"}
            }
            graph.add_edge(source, target, **attributes)

    return graph


def write_json(graph: nx.DiGraph, output_path: Path) -> None:
    payload = {
        "directed": True,
        "nodes": [
            {"id": node_id, **attributes}
            for node_id, attributes in graph.nodes(data=True)
        ],
        "edges": [
            {"source": source, "target": target, **attributes}
            for source, target, attributes in graph.edges(data=True)
        ],
    }
    output_path.write_text(json.dumps(payload, indent=2), encoding="utf-8")
Wire the CLI and inspect the result

The command-line interface coordinates discovery through export. It records individual parse failures so one problematic file does not discard the rest of the repository graph.

  • Switch back to main.py from the previous step.
  • Replace its current prototype with the following imports plus argument parser:
import argparse
from pathlib import Path

from tree_sitter_language_pack import language_count

from builder import build_graph, write_json
from extractor import extract_file
from scanner import collect_source_files


def parse_arguments() -> argparse.Namespace:
    parser = argparse.ArgumentParser(
        description="Convert a catalog-detected codebase into a directed graph."
    )
    parser.add_argument("root", type=Path, help="Project directory to scan")
    parser.add_argument(
        "--output",
        type=Path,
        default=Path("codegraph-out"),
        help="Directory for graph.json and graph.html",
    )
    parser.add_argument(
        "--no-browser",
        action="store_true",
        help="Write graph.html without opening it",
    )
    return parser.parse_args()

What Does the Argument Parser Control?

  • The positional root argument identifies the codebase to scan.
  • The --output option controls the destination directory.
  • The --no-browser option lets the completed CLI accept a file-only run.
  • Save main.py.
  • Confirm the file starts with import argparse.
  • Confirm the current file ends with return parser.parse_args().

Are the CLI Options Underlined?

Check that every parser.add_argument() call closes with a matching parenthesis. Keep return parser.parse_args() inside the function.

Ask for help with the argument parser.

  • Place the cursor below return parser.parse_args() in main.py.
  • Add the scan-to-graph portion of main() by copying this code:
def main() -> int:
    args = parse_arguments()
    root = args.root.resolve()
    output_directory = args.output.resolve()

    if not root.is_dir():
        raise SystemExit(f"Not a directory: {root}")

    output_directory.mkdir(parents=True, exist_ok=True)
    discovered = collect_source_files(root)

    if not discovered:
        raise SystemExit("No catalog-recognized source files were found.")

    extractions = []
    failures: list[tuple[Path, str]] = []

    for path, language in discovered:
        relative_path = path.resolve().relative_to(root).as_posix()
        print(f"Parsing {relative_path} [{language}]")
        try:
            extractions.append(extract_file(path, root, language))
        except Exception as error:
            failures.append((path, str(error)))

    graph = build_graph(extractions)
    json_path = output_directory / "graph.json"

How Does the Scan Stay Resilient?

  • The output directory is created before the export begins.
  • Each discovered file passes through extract_file() separately.
  • A failed extraction is added to failures while the loop continues.
  • The successful extractions pass into build_graph() as one collection.
  • Save main.py.
  • Confirm the new main() function ends at the json_path assignment.
  • Confirm the function has no red syntax underline.

Is main() Showing a Warning?

Keep every line from args = parse_arguments() through the json_path assignment indented inside main().

Ask for help with the main() indentation.

  • In main(), place the cursor below the json_path assignment.
  • Complete the CLI result stage by copying this code:
    write_json(graph, json_path)

    languages = sorted({language for _, language in discovered})
    print(f"Catalog entries available: {language_count()}")
    print(f"Files discovered: {len(discovered)}")
    print(f"Languages detected: {', '.join(languages)}")
    print(f"Graph nodes: {graph.number_of_nodes()}")
    print(f"Graph edges: {graph.number_of_edges()}")
    print(f"Failures: {len(failures)}")
    print(f"JSON: {json_path}")

    for failed_path, message in failures:
        print(f"Skipped {failed_path}: {message}")

    return 0


if __name__ == "__main__":
    raise SystemExit(main())

What Does the Result Stage Report?

  • The write_json() call saves the merged graph at the selected output path.
  • The summary reports catalog availability plus discovered file counts.
  • The summary also reports detected languages plus graph counts.
  • Every recorded failure is printed after the main summary.
  • Save main.py.

Before you run the CLI, predict whether both relationship types survive the graph merge.

  • Run the catalog scan without a browser by using this command:
.\.venv\Scripts\python.exe main.py sample_repo --no-browser

The terminal reports two discovered files with Python as the detected language. It also reports graph counts plus zero failures.

The command writes codegraph-out\graph.json.

  • Return to the project file list in Visual Studio Code.
  • Select graph.json inside codegraph-out.
  • Confirm the top-level directed value is true.
  • Confirm the edge objects include the contains relationship.
  • Confirm the edge objects include the imports relationship.

You have crossed the architecture seam. Separate extraction dictionaries now form one queryable repository graph.

Missing the JSON File or an Edge Type?

Confirm the command runs from the project folder containing main.py. Check the terminal summary for a nonzero failure count.

Ask for help with a missing graph export.

✔️ Awesome, I've got everything!

Excellent. Your CLI now scans the fixture and exports one directed graph with typed relationships.

ⓧ I'd like to double check the full code

Compare your complete main.py with this cumulative version.

import argparse
from pathlib import Path

from tree_sitter_language_pack import language_count

from builder import build_graph, write_json
from extractor import extract_file
from scanner import collect_source_files


def parse_arguments() -> argparse.Namespace:
    parser = argparse.ArgumentParser(
        description="Convert a catalog-detected codebase into a directed graph."
    )
    parser.add_argument("root", type=Path, help="Project directory to scan")
    parser.add_argument(
        "--output",
        type=Path,
        default=Path("codegraph-out"),
        help="Directory for graph.json and graph.html",
    )
    parser.add_argument(
        "--no-browser",
        action="store_true",
        help="Write graph.html without opening it",
    )
    return parser.parse_args()


def main() -> int:
    args = parse_arguments()
    root = args.root.resolve()
    output_directory = args.output.resolve()

    if not root.is_dir():
        raise SystemExit(f"Not a directory: {root}")

    output_directory.mkdir(parents=True, exist_ok=True)
    discovered = collect_source_files(root)

    if not discovered:
        raise SystemExit("No catalog-recognized source files were found.")

    extractions = []
    failures: list[tuple[Path, str]] = []

    for path, language in discovered:
        relative_path = path.resolve().relative_to(root).as_posix()
        print(f"Parsing {relative_path} [{language}]")
        try:
            extractions.append(extract_file(path, root, language))
        except Exception as error:
            failures.append((path, str(error)))

    graph = build_graph(extractions)
    json_path = output_directory / "graph.json"

    write_json(graph, json_path)

    languages = sorted({language for _, language in discovered})
    print(f"Catalog entries available: {language_count()}")
    print(f"Files discovered: {len(discovered)}")
    print(f"Languages detected: {', '.join(languages)}")
    print(f"Graph nodes: {graph.number_of_nodes()}")
    print(f"Graph edges: {graph.number_of_edges()}")
    print(f"Failures: {len(failures)}")
    print(f"JSON: {json_path}")

    for failed_path, message in failures:
        print(f"Skipped {failed_path}: {message}")

    return 0


if __name__ == "__main__":
    raise SystemExit(main())

Your extraction records now power a portable directed graph. Next, you will turn that data into an interactive browser view.

Open the Interactive Graph

Your structured JSON export proves that the directed NetworkX graph exists. However, raw records make containment paths and imports difficult to inspect at a glance.

In this step, you will add a PyVis exporter that turns the graph into a draggable browser view. The exporter keeps its browser resources inline so the finished visualization is self-contained.

In this step, get ready to:
  • Build the PyVis exporter for the directed graph.
  • Connect HTML generation to the existing command-line flow.
  • Open the interactive graph and inspect its relationships.
Build the browser exporter

PyVis can translate the existing NetworkX graph without rebuilding any nodes or edges. This preserves the labels and visual attributes that the extraction pipeline already attached.

  • Select the Windows project folder in the Visual Studio Code file sidebar.
  • Click the new-file icon at the top of the file sidebar.
  • Enter exporter.py as the filename.
  • Press Enter to create the file.
  • Paste the browser exporter into exporter.py using this code:
from pathlib import Path

import networkx as nx
from pyvis.network import Network


def write_html(graph: nx.DiGraph, output_path: Path, open_browser: bool = True) -> None:
    network = Network(height="850px", width="100%", directed=True, bgcolor="#0f172a", font_color="#e2e8f0", cdn_resources="in_line")
    network.from_nx(graph)
    network.barnes_hut(gravity=-3000, spring_length=180)
    network.write_html(str(output_path), notebook=False, open_browser=open_browser)

What does this exporter do?

  • The Network configuration creates a directed canvas with a dark background.
  • The from_nx() call transfers the graph attributes that control labels and hover details.
  • The barnes_hut() settings spread connected nodes across the canvas.
  • The write_html() call writes the self-contained page and optionally opens it in the browser.
  • Save exporter.py.
  • Confirm that exporter.py now appears beside builder.py in the file sidebar.

Seeing an import problem?

Confirm that the filename is exactly exporter.py. Check that Visual Studio Code is using the interpreter from the existing .venv folder.

If the PyVis import remains unresolved, help me check the project interpreter and PyVis installation.

✔️ Awesome, I've got everything!

Your exporter is saved. It is ready to receive the directed graph from the command-line flow.

ⓧ I'd like to double check the full code

Compare your complete exporter.py file with this reference.

from pathlib import Path

import networkx as nx
from pyvis.network import Network


def write_html(graph: nx.DiGraph, output_path: Path, open_browser: bool = True) -> None:
    network = Network(height="850px", width="100%", directed=True, bgcolor="#0f172a", font_color="#e2e8f0", cdn_resources="in_line")
    network.from_nx(graph)
    network.barnes_hut(gravity=-3000, spring_length=180)
    network.write_html(str(output_path), notebook=False, open_browser=open_browser)

How the full file fits together

This file converts one directed NetworkX graph into a self-contained browser page. The open_browser value controls whether that page opens automatically.

Connect HTML export to the CLI

The command-line flow already builds the graph and writes graph.json. It now needs to pass the same graph to write_html() while respecting the existing --no-browser option.

  • In main.py, find the import for build_graph and write_json.
  • Add this exporter import directly below that line:
from exporter import write_html

Why add this import?

This import gives main.py access to the HTML export boundary. Graph construction stays inside builder.py.

  • In main.py, find the block that starts with graph = build_graph(extractions).
  • Replace the block through write_json(graph, json_path) with this wired export block:
    graph = build_graph(extractions)
    json_path = output_directory / "graph.json"
    html_path = output_directory / "graph.html"
    write_json(graph, json_path)
    write_html(graph, html_path, open_browser=not args.no_browser)

How does the browser option work?

Both exporters receive the same completed graph. The expression not args.no_browser opens the page during a normal run and suppresses that launch when the existing option is present.

  • Save main.py.

Before you run the updated CLI, do you expect the browser to open during a normal sample scan?

  • Test the new export path by running this command in the existing project terminal:
.\.venv\Scripts\python.exe main.py sample_repo

What does this first run prove?

The CLI scans sample_repo and writes both output formats. It also passes the graph to PyVis with automatic browser opening enabled.

You should see codegraph-out/graph.html open in your default browser. This confirms that the interactive export is connected to the CLI.

Browser page did not open?

Check the terminal for a skipped-file message or a file-writing problem. Confirm that codegraph-out/graph.html exists before trying to open it from the Windows project folder.

If the file is missing, help me debug why the CLI did not create graph.html.

The terminal report should name the HTML file even when automatic browser opening is disabled. Adding that path makes every export easy to locate.

  • In main.py, find the line that prints json_path.
  • Add this HTML report directly below it:
    print(f"HTML: {html_path}")

Why report the HTML path?

This line prints the resolved location of the browser visualization. It remains useful when --no-browser prevents an automatic launch.

  • Save main.py.

✔️ Awesome, I've got everything!

Your CLI now writes both graph formats. It opens the HTML visualization unless the existing browser option disables that behavior.

ⓧ I'd like to double check the full code

Compare your complete main.py file with this reference.

import argparse
from pathlib import Path

from tree_sitter_language_pack import language_count

from builder import build_graph, write_json
from exporter import write_html
from extractor import extract_file
from scanner import collect_source_files


def parse_arguments() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description="Convert a catalog-detected codebase into a directed graph.")
    parser.add_argument("root", type=Path, help="Project directory to scan")
    parser.add_argument("--output", type=Path, default=Path("codegraph-out"), help="Directory for graph.json and graph.html")
    parser.add_argument("--no-browser", action="store_true", help="Write graph.html without opening it")
    return parser.parse_args()


def main() -> int:
    args = parse_arguments()
    root = args.root.resolve()
    output_directory = args.output.resolve()
    if not root.is_dir():
        raise SystemExit(f"Not a directory: {root}")
    output_directory.mkdir(parents=True, exist_ok=True)
    discovered = collect_source_files(root)
    if not discovered:
        raise SystemExit("No catalog-recognized source files were found.")
    extractions = []
    failures: list[tuple[Path, str]] = []
    for path, language in discovered:
        relative_path = path.resolve().relative_to(root).as_posix()
        print(f"Parsing {relative_path} [{language}]")
        try:
            extractions.append(extract_file(path, root, language))
        except Exception as error:
            failures.append((path, str(error)))
    graph = build_graph(extractions)
    json_path = output_directory / "graph.json"
    html_path = output_directory / "graph.html"
    write_json(graph, json_path)
    write_html(graph, html_path, open_browser=not args.no_browser)
    languages = sorted({language for _, language in discovered})
    print(f"Catalog entries available: {language_count()}")
    print(f"Files discovered: {len(discovered)}")
    print(f"Languages detected: {', '.join(languages)}")
    print(f"Graph nodes: {graph.number_of_nodes()}")
    print(f"Graph edges: {graph.number_of_edges()}")
    print(f"Failures: {len(failures)}")
    print(f"JSON: {json_path}")
    print(f"HTML: {html_path}")
    for failed_path, message in failures:
        print(f"Skipped {failed_path}: {message}")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

How the completed CLI flows

The CLI discovers files and extracts their records before building one directed graph. It sends that graph to the JSON exporter and the HTML exporter before printing the final scan report.

Inspect the interactive graph

The browser view turns graph attributes into visible shapes and relationships. Hover details expose the same language and source metadata stored in the portable export.

Before you run the final check, which relationships do you expect to connect the file nodes to the symbols and imported module?

  • Run the completed CLI against the sample repository with this command:
.\.venv\Scripts\python.exe main.py sample_repo

What should this run produce?

The terminal reports the catalog and graph counts. It also prints the paths to graph.json and graph.html.

The browser opens the directed graph with file boxes and structure dots. The imported module appears as a diamond.

You should see draggable nodes with their saved labels and colors. You should also see directed contains and imports edges with relation labels.

  • Drag the app.py node to confirm that the graph responds interactively.
  • Hover over the app.py node to inspect its language and parse details.
  • Trace a contains edge from a file node to one of its structures.
  • Locate the diamond-shaped helpers external-module node at the end of an imports edge.

That is the full graph engine working. Your scanner now turns a source catalog into portable graph data and an interactive browser visualization.

Graph page looks empty?

Confirm that the terminal reports discovered files and a graph node count above zero. Reload codegraph-out/graph.html after the CLI finishes writing it.

If nodes are still missing, help me trace an empty PyVis graph back through the NetworkX export.

Secret mission

Resolve Imports to Local Files

Your graph can see that `app.py` imports `helpers`, but it still presents that dependency as an external module. Add a resolution pass that rewires unique matches to local file nodes while preserving ambiguous and unmatched imports.

Clean Up Your Resources

Clean Up Your Resources

Everything in this project runs locally on your Windows machine at no ongoing cost. Decide whether to keep your resources, pause your work, or delete the local project entirely.

Resources you used:

  • The .venv virtual environment inside your project folder.
  • The dependency list in requirements.txt.
  • The application source files main.py, scanner.py, extractor.py, builder.py, and exporter.py.
  • The sample_repo fixture containing app.py and helpers.py.
  • The codegraph-out folder containing graph.json and graph.html.

Keep everything running

No action needed. Choose this if you want to scan more repositories or extend the graph engine.

  • Keep .venv so the pinned dependencies remain ready.
  • Keep the application source files for future graph features.
  • Keep sample_repo as a quick test fixture.
  • Keep codegraph-out so you can revisit the exported graph.
  • Retain the shared grammar cache used by tree-sitter-language-pack for faster future scans.

Pause - I'll come back to this later

The CLI exits after each export. Pausing only means closing the windows you no longer need.

  • Close the browser tab displaying graph.html.
  • Close the terminal from earlier.
  • Keep the project folder so every source file remains available.
  • Retain the shared grammar cache for your next scan.

Delete - I don't want to use this again

Deleting the folder is permanent. The command below targets only the project folder name you enter.

  • Switch back to the terminal from earlier.
  • Confirm the terminal prompt ends with your current project folder name.
  • Record that folder name here: your project folder name.
  • Remove the complete project folder by running these commands:
cd ..
cmd /c rmdir /s /q "[[PROJECT_FOLDER="your project folder name"]]"
dir

What Do These Commands Remove?

  • The first command moves the terminal one level above your project folder.
  • The second command deletes the named folder with every file inside it.
  • The final command lists the remaining folders so you can verify the deletion.

You should no longer see your project folder name in the listing. You've cleared the complete local project in one pass.

Still See the Project Folder?

  • Close Visual Studio Code if it still has a project file open.
  • Move the terminal to the folder above your project before running the deletion commands again.
  • Ask for help with the Windows cleanup command.

The shared grammar cache remains available because it sits outside the project folder. Its optional cleanup is separate from this project deletion.

Nice Work!

Nice Work!

Excellent work! Your catalog-driven graph engine now turns source files into an interactive repository map.

You've learned how to:

  • Apply catalog-driven source discovery to detect supported languages without maintaining a manual extension table.
  • Convert Tree-sitter output into normalized extraction records with stable repository-relative IDs. Represent nesting through typed contains relationships. Represent dependencies through typed imports relationships.
  • Merge per-file records into a directed NetworkX graph. Export the model as portable JSON. Open the same data as a self-contained PyVis visualization.
  • Secret Mission: Use local import resolution to rewire uniquely matched imports to local file nodes. Preserve ambiguous imports as external modules. Preserve unmatched imports as external modules.

Ready to quiz yourself?