_CallRootVisitor.visit_FunctionDef
def visit_FunctionDef(
node
):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?
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.
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.
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.
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.
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.
Return structured calls-before-definitions and missing callable import warnings.
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.
Return style-check lines for calls before definitions/imports and missing callable imports.
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.
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.
Return structured definitions, callers, and callees for a symbol.
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.
Return formatted caller usage snippets for this symbol.
Return this symbol with caller and callee relationship data.
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(),
}Return the compact public view of symbol graph data.
Return a compact caller/callee summary for one or more symbols.
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.
Print definitions, callers, and callees for a notebook symbol.
Print cross-notebook calls to imported private _ symbols.
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)Return notebook symbols with placement and reuse evidence.
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.
The notebook executes from top to bottom. The catalog builder and its helpers therefore appear before the knowledge-graph builder, which calls symbol_catalog.
Return a typed graph of notebook, cell, symbol, import, and heading structure.
Print a typed notebook knowledge graph summary, or JSON when requested.
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.
Return existing symbols and notebooks to inspect before implementing code.
Return ranked notebook candidates plus evidence for a symbol or source snippet.
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, callerThe 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_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 sitesdef _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 callersdef _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 resultdef _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 rowsdef _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}")The order checker exposes calls that appear before their definitions or imports, which makes notebook execution problems visible before runtime.
notebook_order_problem_lines converts the structured warnings into concise style-check lines that are easy to print or filter.
symbol_graph_data is the structured entry point for callers that need definitions, caller sites, and callee information without parsing report text.
symbol_graph_public_data keeps the automation-facing shape small while preserving caller usage records.
symbol_usage_summary is the compact human-facing view for checking several symbols at once.
The report function prints a short orientation view for a symbol; use the data functions above when a caller needs machine-readable details.
private_symbol_report is a boundary check for private helpers imported across notebooks.
symbol_connection follows the shortest approximate call chain between two symbols and prints whether a connection was found.
notebook_knowledge_graph_data returns a typed graph of notebook, cell, symbol, import, and heading nodes.
notebook_knowledge_graph renders the typed graph as a concise summary for quick inspection.
symbol_catalog records where symbols live and the text evidence that helps agents decide what to reuse.
reuse_advice ranks existing symbols and notebooks before new code is written.
placement_advice ranks likely notebook locations for a symbol or source snippet.
notebook_advice_problems collects conservative reuse, placement, and private-boundary diagnostics for style reports.