Notebook symbol graph

Statically match notebook definitions and calls so agents can see which cells define, call, and depend on a symbol.

This notebook adds a static map across notebooks. It does not execute project code; it parses cells with ast, records where symbols are defined, and reports which cells call those symbols.

That gives agents a fast way to answer questions like “where is this helper used?” before editing a private function or moving code between notebooks.

The graph is a navigation aid, not a runtime dependency analyzer. It answers practical maintenance questions: where is this symbol defined, who calls it, what does it call, can one symbol reach another through static calls, and are any notebooks importing private helpers that should stay local?

symbol_graph(path="nbs", symbol="write_nb")
symbol_connection(path="nbs", start="symbol_connection", end="_resolve_callees")
private_symbol_report(path="nbs")

Production contract

The graph tools are maintenance aids, not runtime dependency analyzers. Production behavior is limited to static definitions, callers, callee summaries, order warnings, and private-boundary reports with modest claims about accuracy.

1. Parse notebooks and collect evidence

The first chapter turns notebook cells into small, inspectable records. Each helper has one job so the order of the parser is visible to the reader.

Discovering definitions and calls

The parser records function, class, and method definitions, then walks call expressions inside each parsed tree. Both fully qualified names and short names are kept because notebooks often import helpers into local scope.

Building graph records

Each code cell can contribute definition records and caller records. The collected graph is deliberately simple dictionaries so reports, tests, and future tools can inspect it without a graph database.

Cell order warnings

Notebook cells are executed in order, so a top-level call should not appear before the cell that defines or imports its callable. The order checker also looks inside function bodies for callable roots that are never defined or imported, which catches rarely exercised missing imports before runtime.

The checker treats names bound inside ordinary blocks as local to the cell. That matters for context-manager examples such as with write_demo_notebook(...) as path: and capture helpers such as out = StringIO(): those variables are setup, not missing imports.


source

_CallRootVisitor.visit_FunctionDef

def visit_FunctionDef(
    node
):

source

_CallRootVisitor.visit_AsyncFunctionDef

def visit_AsyncFunctionDef(
    node
):

source

_CallRootVisitor.visit_Lambda

def visit_Lambda(
    node
):

source

_CallRootVisitor.visit_ListComp

def visit_ListComp(
    node
):

source

_CallRootVisitor.visit_SetComp

def visit_SetComp(
    node
):

source

_CallRootVisitor.visit_DictComp

def visit_DictComp(
    node
):

source

_CallRootVisitor.visit_GeneratorExp

def visit_GeneratorExp(
    node
):

source

_CallRootVisitor.visit_Call

def visit_Call(
    node
):

source

notebook_order_problems

def notebook_order_problems(
    path:str='nbs', nb:NoneType=None
):

Return structured calls-before-definitions and missing callable import warnings.

Editing without committing

notebook_order_problems accepts an in-memory notebook so edit tools can warn about a call that now precedes its definition before they commit the change.

order_problems = notebook_order_problems("nbs/10_graph.ipynb")
assert isinstance(order_problems, list)

source

notebook_order_problem_lines

def notebook_order_problem_lines(
    path:str='nbs'
):

Return style-check lines for calls before definitions/imports and missing callable imports.

2. Resolve and report relationships

The graph is approximate by design. Matching by exact or short symbol name is enough to surface likely callers and callees, which is the useful pre-edit signal for this project.

A symbol connection follows definition-to-callee edges with a bounded breadth-first search. That keeps the answer practical: show the shortest static call chain that explains how one symbol can reach another, and stop before approximate short-name matching turns into noise.

Formatting reports

The reporting helpers turn graph records into compact text for humans and complete structured data for automation. Text output stays short enough for agent context, while JSON output keeps every caller usage line so migration scripts do not have to scrape truncated prose.


source

symbol_graph_data

def symbol_graph_data(
    path, symbol
):

Return structured definitions, callers, and callees for a symbol.

Symbols as graph entities

A symbol becomes more useful when its caller and callee relationships travel with it. The graph layer patches those relationship methods onto NotebookSymbol because this notebook owns the static call graph.


source

NotebookSymbol.definitions

def definitions():

Return definition records for this symbol.


source

NotebookSymbol.callers

def callers():

Return call records that reference this symbol.


source

NotebookSymbol.caller_usages

def caller_usages():

Return formatted caller usage snippets for this symbol.


source

NotebookSymbol.callees

def callees():

Return symbols called by this symbol.


source

NotebookSymbol.relationships_to_record

def relationships_to_record():

Return this symbol with caller and callee relationship data.


source

NotebookSymbol.relationships_to_xml

def relationships_to_xml():

Return an XML-shaped symbol relationship view for LLM context.

_graph_example_data = {
    "symbol": "helper",
    "definitions": [{"symbol": "helper", "path": "demo.ipynb", "cell_id": "abc", "cell_idx": 1, "module": "demo", "kind": "function"}],
    "callers": [{"path": "demo.ipynb", "cell_id": "def", "cell_idx": 2, "calls": ["helper"]}],
    "caller_usages": [{"path": "demo.ipynb", "cell_id": "def", "lineno": 1, "line": "helper()"}],
    "callees": ["leaf"],
    "graph": {"definitions": []},
}
_graph_example = NotebookSymbol.from_graph_data(_graph_example_data)
{
    "definitions": _graph_example.definitions(),
    "callers": _graph_example.callers(),
    "caller_usages": _graph_example.caller_usages(),
    "callees": _graph_example.callees(),
}

source

symbol_graph_public_data

def symbol_graph_public_data(
    data
):

Return the compact public view of symbol graph data.


source

symbol_usage_summary

def symbol_usage_summary(
    path, symbols
):

Return a compact caller/callee summary for one or more symbols.

Public graph reports

symbol_graph focuses on one symbol’s definitions, callers, caller usage lines, and callees. symbol_connection answers the next question: whether one symbol can reach another by following static callee edges, returning the shortest chain it finds. Default output is compact text for quick orientation; json_output=True returns structured data for programmatic refactors. private_symbol_report looks for cross-notebook calls to private helpers, which is useful when deciding whether a private function can be changed safely.


source

symbol_graph

def symbol_graph(
    path:str='nbs', # Notebook file, directory, or glob to scan
    symbol:str='', # Function, class, or Class.method to inspect
    json_output:bool=False, # Print complete structured JSON instead of compact text
):

Print definitions, callers, and callees for a notebook symbol.


source

private_symbol_report

def private_symbol_report(
    path:str='nbs', # Notebook file, directory, or glob to scan
):

Print cross-notebook calls to imported private _ symbols.


source

symbol_connection

def symbol_connection(
    path:str='nbs', # Notebook file, directory, or glob to scan
    start:str='', # Symbol where traversal starts
    end:str='', # Symbol to find by following callees
    max_depth:int=6, # Maximum number of edges to follow
    json_output:bool=False, # Print complete structured JSON instead of compact text
):

Print the shortest static callee chain connecting two notebook symbols.

root = demo_path("10_graph_example")
try:
    root.mkdir()
    write_nb(new_nb([
        mk_cell("#| default_exp demo"),
        mk_cell("#| export\ndef helper():\n    return 1\n\ndef target():\n    return helper()"),
        mk_cell("target()"),
    ]), root / "demo.ipynb")
    _example_symbol_graph(str(root), "helper")
finally:
    remove_demo_path(root)

source

symbol_catalog

def symbol_catalog(
    path:str='nbs'
):

Return notebook symbols with placement and reuse evidence.

3. Build a notebook knowledge graph

notebook_knowledge_graph_data exports a typed, deterministic graph for local notebook structure. Edges carry confidence and evidence so call/import facts stay separate from heuristic similarity links.

Definition order for the knowledge graph

The notebook executes from top to bottom. The catalog builder and its helpers therefore appear before the knowledge-graph builder, which calls symbol_catalog.


source

notebook_knowledge_graph_data

def notebook_knowledge_graph_data(
    path:str='nbs'
):

Return a typed graph of notebook, cell, symbol, import, and heading structure.


source

notebook_knowledge_graph

def notebook_knowledge_graph(
    path:str='nbs', json_output:bool=False
):

Print a typed notebook knowledge graph summary, or JSON when requested.

4. Offer conservative reuse advice

The advisor turns graph evidence into conservative suggestions. It never moves code; it records why an existing symbol or notebook is worth inspecting before an agent writes another helper.


source

reuse_advice

def reuse_advice(
    goal, path:str='nbs', source:NoneType=None, top_k:int=5
):

Return existing symbols and notebooks to inspect before implementing code.


source

placement_advice

def placement_advice(
    path:str='nbs', symbol:NoneType=None, source:NoneType=None, notebook:NoneType=None, top_k:int=5
):

Return ranked notebook candidates plus evidence for a symbol or source snippet.


source

notebook_advice_problems

def notebook_advice_problems(
    path:str='nbs'
):

Return reuse and placement advisory diagnostics for notebook style reports.

def _write_graph_test_notebooks(root):
    root.mkdir()
    lib = root / "lib.ipynb"
    caller = root / "caller.ipynb"
    write_nb(new_nb([
        mk_cell("#| default_exp lib"),
        mk_cell("#| export\ndef helper():\n    return 1\n\ndef _secret():\n    return helper()\n\ndef target():\n    return helper()"),
    ]), lib)
    write_nb(new_nb([mk_cell("from nbskill.lib import target, _secret\nvalue = target()\n_secret()")]), caller)
    return lib, caller

5. Explore the graph

The symbol graph can answer questions that plain text search only approximates. It can connect a definition to the cells that call it, show what that definition itself calls, rank symbols by likely edit blast radius, and expose private helpers that have crossed notebook boundaries.

These prototype cells stay non-exported on purpose. They test strategies over the current nbs graph before deciding whether any report deserves a public Python API. The results are static-analysis hints, not deletion permission: dynamic imports, entry points, examples, and short-name collisions still need human review.

def _explore_definition_id(record): return (record.get("module"), record["symbol"], record["path"], record["cell_id"])
def _explore_call_sites_by_name(graph):
    sites = defaultdict(list)
    for caller in graph["callers"]:
        for site in caller.get("call_sites", ()):
            sites[site.get("name")].append(dict(path=caller["path"], cell_id=caller["cell_id"], cell_idx=caller["cell_idx"], lineno=site.get("lineno"), line=site.get("line")))
    return sites
def _explore_unique_sites(rows):
    seen, unique = set(), []
    for row in rows:
        key = (row["path"], row["cell_id"], row.get("lineno"), row.get("line"))
        if key in seen: continue
        seen.add(key)
        unique.append(row)
    return unique
def _explore_callers_by_definition(graph):
    sites = _explore_call_sites_by_name(graph)
    callers = {}
    for record in graph["definitions"]:
        names = dict.fromkeys([record["symbol"], symbol_short_name(record["symbol"])])
        rows = [site for name in names for site in sites.get(name, ())]
        callers[_explore_definition_id(record)] = _explore_unique_sites(rows)
    return callers
def _explore_unused_candidates(graph):
    callers = _explore_callers_by_definition(graph)
    result = {"public": [], "private": []}
    for record in graph["definitions"]:
        if callers[_explore_definition_id(record)]: continue
        visibility = "private" if record["symbol"].startswith("_") else "public"
        result[visibility].append(dict(symbol=record["symbol"], kind=record["kind"], module=record.get("module"), path=record["path"], cell_id=record["cell_id"], caveat="check Python callers, docs, and dynamic calls before deleting"))
    for rows in result.values(): rows.sort(key=lambda row: (row["module"] or "", row["symbol"]))
    return result
def _explore_hotspots(graph):
    callers = _explore_callers_by_definition(graph)
    rows = []
    for record in graph["definitions"]:
        fan_in = len({(site["path"], site["cell_id"]) for site in callers[_explore_definition_id(record)]})
        fan_out = len([symbol for symbol in _resolve_callees(graph, record.get("calls", ())) if symbol != record["symbol"]])
        if fan_in or fan_out:
            rows.append(dict(symbol=record["symbol"], module=record.get("module"), path=record["path"], cell_id=record["cell_id"], fan_in=fan_in, fan_out=fan_out, score=fan_in * 2 + fan_out))
    return sorted(rows, key=lambda row: (row["score"], row["fan_in"], row["fan_out"]), reverse=True)
def _explore_private_boundary_leaks(graph):
    definitions = {(row.get("module"), row["symbol"]): row for row in graph["definitions"]}
    rows = []
    for imported in graph["imports"]:
        if not symbol_short_name(imported["symbol"]).startswith("_"): continue
        definition = definitions.get((imported["module"], imported["symbol"]))
        if not definition or definition["path"] == imported["path"]: continue
        for caller in graph["callers"]:
            if caller["path"] != imported["path"]: continue
            sites = [site for site in caller.get("call_sites", ()) if _call_matches_symbol(site.get("name"), imported["local"])]
            if sites:
                row = dict(symbol=imported["symbol"], local=imported["local"], defined_path=definition["path"])
                row.update(defined_cell=definition["cell_id"], called_path=caller["path"], called_cell=caller["cell_id"], line=sites[0].get("line"))
                rows.append(row)
    return rows
def _explore_ambiguous_short_names(graph):
    sites = _explore_call_sites_by_name(graph)
    by_short = defaultdict(list)
    for record in graph["definitions"]: by_short[symbol_short_name(record["symbol"])].append(record)
    rows = []
    for name, records in by_short.items():
        locations = {(row.get("module"), row["symbol"], row["path"], row["cell_id"]) for row in records}
        if len(locations) <= 1: continue
        row = dict(short_name=name, definition_count=len(locations), call_site_count=len(sites.get(name, ())))
        row.update(symbols=sorted({row["symbol"] for row in records})[:6], caveat="short-name matching can overconnect this symbol")
        rows.append(row)
    return sorted(rows, key=lambda row: (row["call_site_count"], row["definition_count"]), reverse=True)
def _explore_notebook_hotspots(graph, hotspots, private_leaks, order_problems=(), style=None):
    def_counts = Counter(row["path"] for row in graph["definitions"])
    central = Counter(row["path"] for row in hotspots[:30])
    leaks = Counter(row["called_path"] for row in private_leaks)
    order = Counter(row["path"] for row in order_problems)
    style_rows = (style or {}).get("notebook_problems", ())
    style_counts = Counter(row["path"] for row in style_rows)
    rows = []
    for path in set(def_counts) | set(central) | set(leaks) | set(order) | set(style_counts):
        score = central[path] * 3 + leaks[path] * 2 + order[path] + style_counts[path]
        row = dict(path=path, definitions=def_counts[path], central_symbols=central[path])
        row.update(private_leaks=leaks[path], order_warnings=order[path], style_warnings=style_counts[path], score=score)
        rows.append(row)
    return sorted(rows, key=lambda row: (row["score"], row["definitions"]), reverse=True)

The first useful reports are comparative rather than absolute. A symbol with no resolved callers is a cleanup candidate, not proof of dead code. A high fan-in or fan-out symbol deserves more careful tests before editing. A private boundary leak shows where notebook-local code has become a cross-module dependency. Ambiguous short names show where the current graph can lie by matching Class.method and method too generously.

_explore_scope = "nbs/*.ipynb"
_explore_notebook = notebook_paths("nbs/10_graph.ipynb")[0]
_explore_graph = _collect_graph(_explore_scope)
_explore_style = style_report(_explore_notebook)
_explore_unused = _explore_unused_candidates(_explore_graph)
_explore_hotspot_rows = _explore_hotspots(_explore_graph)
_explore_private_leaks = _explore_private_boundary_leaks(_explore_graph)
_explore_ambiguous = _explore_ambiguous_short_names(_explore_graph)
_explore_notebooks = _explore_notebook_hotspots(_explore_graph, _explore_hotspot_rows, _explore_private_leaks, notebook_order_problems(_explore_scope), _explore_style)
_explore_connection_pairs = [
    ("symbol_connection", "_resolve_callees"),
    ("symbol_graph", "_caller_usage_records"),
    ("style_check", "notebook_order_problem_lines"),
]
_explore_connections = [
    _symbol_connection_public_data(_symbol_connection_data(_explore_scope, start, end, max_depth=6))
    for start, end in _explore_connection_pairs
]

print(f"graph: {len(_explore_graph['definitions'])} definitions, {len(_explore_graph['callers'])} caller cells, {len(_explore_graph['imports'])} imports")
print("hotspots:", ", ".join(row["symbol"] for row in _explore_hotspot_rows[:8]))
print("private leaks:", ", ".join(row["symbol"] for row in _explore_private_leaks[:6]))
print("unused public candidates:", ", ".join(row["symbol"] for row in _explore_unused["public"][:6]))
print("ambiguous names:", ", ".join(row["short_name"] for row in _explore_ambiguous[:6]))
print("notebook hotspots:", ", ".join(row["path"] for row in _explore_notebooks[:4]))
for row in _explore_connections:
    chain = " -> ".join(row["symbols"]) if row["found"] else "not found"
    print(f"connection {row['start']} to {row['end']}: {chain}")

Public behavior

The order checker exposes calls that appear before their definitions or imports, which makes notebook execution problems visible before runtime.

_order_examples = notebook_order_problems("nbs/10_graph.ipynb")
print(f"order diagnostics: {len(_order_examples)}")

notebook_order_problem_lines

notebook_order_problem_lines converts the structured warnings into concise style-check lines that are easy to print or filter.

_order_lines_example = notebook_order_problem_lines("nbs/10_graph.ipynb")
print("\n".join(_order_lines_example[:3]) or "no order warnings")

symbol_graph_data

symbol_graph_data is the structured entry point for callers that need definitions, caller sites, and callee information without parsing report text.

_graph_data_example = symbol_graph_data("nbs/10_graph.ipynb", "symbol_graph")
print(dict(symbol=_graph_data_example["symbol"], definitions=len(_graph_data_example["definitions"]), callers=len(_graph_data_example["callers"])))

symbol_graph_public_data

symbol_graph_public_data keeps the automation-facing shape small while preserving caller usage records.

_public_graph_example = symbol_graph_public_data(_graph_data_example)
print(dict(symbol=_public_graph_example["symbol"], callers=len(_public_graph_example["callers"]), usages=len(_public_graph_example["caller_usages"])))

symbol_usage_summary

symbol_usage_summary is the compact human-facing view for checking several symbols at once.

_usage_summary_example = symbol_usage_summary("nbs/10_graph.ipynb", ["symbol_graph", "symbol_connection"])
print(_usage_summary_example.splitlines()[0])

Public behavior

The report function prints a short orientation view for a symbol; use the data functions above when a caller needs machine-readable details.

symbol_graph(path="nbs/10_graph.ipynb", symbol="symbol_graph")

private_symbol_report

private_symbol_report is a boundary check for private helpers imported across notebooks.

private_symbol_report(path="nbs/10_graph.ipynb")

symbol_connection

symbol_connection follows the shortest approximate call chain between two symbols and prints whether a connection was found.

symbol_connection(path="nbs/10_graph.ipynb", start="symbol_graph", end="_collect_graph")

notebook_knowledge_graph_data

notebook_knowledge_graph_data returns a typed graph of notebook, cell, symbol, import, and heading nodes.

_knowledge_graph_example = notebook_knowledge_graph_data("nbs/10_graph.ipynb")
print(dict(nodes=len(_knowledge_graph_example["nodes"]), edges=len(_knowledge_graph_example["edges"]), issues=len(_knowledge_graph_example["issues"])))

notebook_knowledge_graph

notebook_knowledge_graph renders the typed graph as a concise summary for quick inspection.

notebook_knowledge_graph(path="nbs/10_graph.ipynb")

symbol_catalog

symbol_catalog records where symbols live and the text evidence that helps agents decide what to reuse.

_catalog_example = symbol_catalog("nbs/10_graph.ipynb")
print(f"catalog symbols: {len(_catalog_example['symbols'])}")

reuse_advice

reuse_advice ranks existing symbols and notebooks before new code is written.

_reuse_example = reuse_advice("notebook graph parser", path="nbs/10_graph.ipynb", top_k=3)
print(f"reuse matches: {len(_reuse_example['matches'])}")

placement_advice

placement_advice ranks likely notebook locations for a symbol or source snippet.

_placement_example = placement_advice(path="nbs/10_graph.ipynb", symbol="symbol_graph", top_k=3)
print(f"placement candidates: {len(_placement_example['candidates'])}")

notebook_advice_problems

notebook_advice_problems collects conservative reuse, placement, and private-boundary diagnostics for style reports.

_advice_problems_example = notebook_advice_problems("nbs/10_graph.ipynb")
print(f"advice diagnostics: {len(_advice_problems_example)}")