Sentinel Code Graph CLI
Async CLI that scans a directory or file, extracts structural nodes (files, classes, functions, methods, interfaces, types, imports) and edges (contains, imports, calls) with raw tree-sitter, and stores them in Ladybug (embedded property graph, zero setup).
- Default database is a known-location Ladybug file
(
~/.codegraph/graph.lbdb), so query time never guesses where the index lives. Pass--dbto any command to override it. --db :memory:indexes ephemerally (lost when the process exits).- Scope: Python, TypeScript/JavaScript, Go. (
queries.pystays on disk, unwired.) - One database holds exactly one index:
--overwriteflushes the whole db first, so re-indexing is idempotent. --no-persistdry-runs the pipeline (parse → nodes → edges → print, no DB writes).
Install
From the repo root (workspace member, latest pinned deps in uv.lock):
uv sync
Usage
# index a tree into the default DB (re-runnable; --overwrite flushes first)
uv run --package codegraph python -m codegraph.cli index ./packages/api/src --overwrite
# index a single file (root = its parent dir; must be a supported language)
uv run --package codegraph python -m codegraph.cli index ./packages/api/main.py
# inspect the database
uv run --package codegraph python -m codegraph.cli stats
# index and print the hierarchy tree of the indexed root
uv run --package codegraph python -m codegraph.cli index ./src --output tree
# dump every collected node (kind, lines, parent, children, callees)
uv run --package codegraph python -m codegraph.cli index ./src --output nodes
# list every calls edge (caller -> callee, implementation order)
uv run --package codegraph python -m codegraph.cli index ./src --output calls
# dry-run: print without persisting
uv run --package codegraph python -m codegraph.cli index ./src --no-persist --output tree
# Ephemeral index (no file written)
uv run --package codegraph python -m codegraph.cli index ./src --db :memory: --output tree
# explore the index (read-only; works from anywhere — default DB is known)
uv run --package codegraph python -m codegraph.cli query overview
uv run --package codegraph python -m codegraph.cli query files
uv run --package codegraph python -m codegraph.cli query search --name reviewWorkflowV2
uv run --package codegraph python -m codegraph.cli query callees --name reviewWorkflowV2 --json
Flow
amain parses args, then three linear stages in pipeline.py:
read(target) -> build_graph(root, items) -> out(db, root, graph, ...)
- read — discover (
walk: suffix → language, prune noise dirs, skip oversize/undecodable) + read off the loop →ScannedSources(root, items). I/O. - build_graph — pure, two passes: (1) collect —
parse_file_inputper file (blank source or unsupported language → skip, counted) into nodes +contains/importsplus buffered call sites; (2) link —resolve_call_edgesjoins sites against the(file, name)definition registry + per-file import maps (alias-aware, joined on a single candidate: the defining-module original when the import carries one, else the bound name; Go dot-imported files are a fallback) intocallswith real node ids, dropping unresolvable sites. Merge →BuiltGraph(root, files, skipped, nodes, edges). No I/O, no DB. - out — persist (
create_all,clear_allwhen--overwrite,add_all) then print (summary|tree|nodes|calls) →IndexResult. I/O.
cli.py holds only arg parsing + amain wiring + run_stats;
__main__.py calls cli.main().
Python emitter
Each Python file is walked once (collect_python_file):
Node + Edge(CONTAINS) with base:name:start:end ids for defs,
Node(IMPORT) + Edge(IMPORTS) per imported name (keeping the
as-alias original, e.g. from utils import helper as h records bound
h + original helper), and bare-name calls buffered unresolved with
their call-site line. The link phase then joins each site — same-file
hit first, else the import map's resolved file + original name — into
calls edges with real node ids. Absolute imports tolerate a
root-relative prefix on indexed paths (src/app/… satisfies
import app.…). Builtins, stdlib, third-party, star-import calls,
attribute calls (obj.method(…), self.x(…)), and module-level call
sites yield no edges. Decorator applications (@retry,
@with_logging(…)) buffer as call sites on the decorated def/class
with the @-line and resolve like ordinary calls (attribute decorators
such as @app.get(…) are skipped). One edge per caller → callee pair,
each stamped with its call-site line. Calls render in implementation
order, not alphabetical.
TypeScript / JavaScript collector
Each TS/JS file is walked once (collect_ts_file): classes,
functions, methods, interfaces, type aliases, and arrow-bound consts
(export wrappers are transparent) → Node + Edge(CONTAINS);
imports keep alias originals (import {a as b} → bound b +
original a; default imports join on the bound name); bare calls and
new C() constructions buffer unresolved. Relative specifiers resolve
with extension + /index fallbacks; bare (npm) specifiers never
resolve. Member calls (obj.m()), builtins, and module-level sites
yield no edges.
Go collector
Each Go file is walked once (collect_go_file): funcs, methods
(reparented to their receiver struct when same-file), struct types →
class, interface types → interface (with method_elem children as
methods), other named types → type. Blank imports bind *, dot
imports bind . (their target files are searched for otherwise
unresolved bare sites). Import tails match indexed .go stems;
selector calls (pkg.Fn()), builtins, and package-level sites yield
no edges.
Schema (Ladybug)
CodeNode:id (PK), root, file_path, kind (file|class|function|method|interface|type|import), name, language, start_line, end_line, parent_id, is_placeholderContains/Imports/Calls: rels betweenCodeNoderows (target_moduleonImports,site_lineonCalls).
site_line is the 1-based call-site line inside the caller (Calls
rels only; NULL = unknown). Callees sort by it.
Node ids (all languages)
- file node:
base(path anchored at the CLI root, e.g.workflows/review.py) - def node:
base:name:start:end(e.g.workflows/review.py:helper:10:15) - import node:
base:import:<name>:<line>
Every stored Calls edge points at real node ids — unresolvable call
sites are dropped at build time, so no placeholder rows exist
(is_placeholder stays on the schema for old databases only).
Querier's check:
// 1. file's nodes (find the caller)
MATCH (f:CodeNode {id: '<file>'})-[:Contains]->(n) RETURN n;
// 2. caller's callees, in implementation order
MATCH (c:CodeNode {id: '<caller id>'})-[e:Calls]->(d) RETURN d ORDER BY e.site_line;
Tables are created with create_all (IF NOT EXISTS): after a
schema change, delete the .lbdb file and re-index.
Querying (agent ladder)
The query verbs are read-only and built for agents that don't know
node id shapes — every step returns the ids the next step needs:
query overview— what's indexed (counts by kind/edge/language).query files— rel paths (a file node id is its rel path).query search --name <fragment> [--kind …] [--file …] [--limit N]— substring-match def names → full rows with ids. Zero hits and truncation (truncated: true) tell the agent to rephrase/narrow.query node|callees|callers|children --id <id>(or--nameexact + optional--file; ambiguity is an error listing hits) andquery imports --file <rel>— drill down; new names chain back tosearch.
--root narrows to one indexed root (default: all), --json emits
the stable chaining envelope
({root, verb, count, truncated, items[]} with
{id, kind, name, file_path, language, start_line, end_line, parent_id} plus site_line on calls items and target_module on
imports items). Querying a database that was never indexed exits 1
with a hint instead of an empty result — and read paths never create
files or directories (only index persistence creates
~/.codegraph/).
Module map
cli.py— arg parsing,amainwiring (read → build_graph → out),main,run_stats,run_queryquery.py— pure exploration shaping:search_nodes,exact_matches, JSON envelope, human renderingpipeline.py—read/build_graph/out,ScannedSources/BuiltGraph/IndexResult,OutputMode,print_tree/print_nodes/print_callsparser/__init__.py— barrel: re-exports only, no logicparser/rows.py—FileRows,build_file_rows,build_python_file_rowsparser/links.py—ImportEntry,build_file_import_map,build_dot_import_targets,resolve_call_edges(+ per-language module-specifier resolvers)parser/lang_python.py— Python collect phase (collect_python_file)parser/lang_typescript.py— TS/JS collect phase (collect_ts_file)parser/lang_go.py— Go collect phase (collect_go_file)parser/queries.py— kept, unwiredparser/base.py—ParsedFile/ParsedDefinition/ParsedImport/ParsedCallIR typesparser/raw_core.py— tree-sitterparse,walk,span, text helpersmodels.py—Node/Edgedataclasses (+NodeKind,EdgeKind)graph_store.py—LadybugStore(UNWIND ingest,clear_all,add_all, count/list queries, narrowget_node/callees/callers/children/file_imports/list_filesreads)tree.py—GraphSnapshot,render_tree(nesting bycontains, calls insite_lineorder)walk.py— suffix → language discovery, noise-dir pruningconfig.py—DEFAULT_DB(~/.codegraph/graph.lbdb) +--dbpath resolution: file path or:memory:(ResolvedDb)
Design notes
- Pipeline is functional:
read -> build_graph -> out. Extractors and builders are pure; I/O lives only at the edge (discover + read, persist, print). - Build output is a simple flat graph:
BuiltGraphcarries mergednodes+edgestuples only. Every consumer (persist, snapshot, printers) derives what it needs from those two lists. - Graph values are frozen dataclasses holding tuples — immutable snapshots, safe to share between build and out.
- Parsing uses raw
tree_sitter_language_pack.get_parser().parse(). Import names are recovered from the statement source text in pure Python. - Parsing runs on the event-loop thread (tree-sitter objects are not
thread-safe); only file I/O goes through
asyncio.to_thread. - Ladybug is embedded: one
AsyncConnectionper store, andout()holds a single store from persist through the snapshot reads, so:memory:databases stay alive for the whole call. - Calls are bare-name only (
name(…)), alias-aware via the import map (from utils import helper as h+h()resolves tohelper; TSimport {a as b}+b()resolves toa; TS default imports join on the bound name; Go dot-imported files are a fallback);new C()in TS counts as a call. Attribute / member calls (obj.method(),self.x(),pkg.Fn()), builtins, stdlib, third-party, star-import calls, and module-level call sites yield no edges. - Files with unsupported suffixes are discovered, then skipped
(
(N skipped)in the summary).
Tests
uv run --package codegraph pytest tests
Release files for sentinel-codegraph 0.3.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| sentinel_codegraph-0.3.0.tar.gz | 49.6 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| sentinel_codegraph-0.3.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 101.9 kB
Release files / sentinel_codegraph-0.3.0.tar.gz
| Download URL | sentinel_codegraph-0.3.0.tar.gz |
|---|---|
| Size | 49.6 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
4aab88d5b141d229dfddda3003f2cbccdd2a37a81bae5d3ddadc72d308c77f00
|
|
BLAKE2b-256 checksum How to use checksums |
95fdfcc2a66eea1720f74813e32ef193535190d8a37d1f535526c0455dde625a
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.11.25 {"installer":{"name":"uv","version":"0.11.25","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":null,"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
|
Release files / sentinel_codegraph-0.3.0-py3-none-any.whl
| Download URL | sentinel_codegraph-0.3.0-py3-none-any.whl |
|---|---|
| Size | 52.2 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
d57f0eb7a97390079eb47b44d3b6764c286cdb8c16c39b85988361a81f2b323c
|
|
BLAKE2b-256 checksum How to use checksums |
84fba8955f0e31590f8db5fe7b18fc1bb89ae804093118e6f74b43d4739aaf67
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.11.25 {"installer":{"name":"uv","version":"0.11.25","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":null,"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
|