sentinel-codegraph 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- codegraph/__init__.py +5 -0
- codegraph/__main__.py +6 -0
- codegraph/cli.py +475 -0
- codegraph/config.py +55 -0
- codegraph/graph_store.py +455 -0
- codegraph/models.py +87 -0
- codegraph/parser/__init__.py +27 -0
- codegraph/parser/base.py +114 -0
- codegraph/parser/lang_go.py +524 -0
- codegraph/parser/lang_python.py +458 -0
- codegraph/parser/lang_typescript.py +554 -0
- codegraph/parser/links.py +314 -0
- codegraph/parser/queries.py +114 -0
- codegraph/parser/raw_core.py +66 -0
- codegraph/parser/rows.py +191 -0
- codegraph/pipeline.py +370 -0
- codegraph/query.py +125 -0
- codegraph/tree.py +230 -0
- codegraph/walk.py +114 -0
- sentinel_codegraph-0.3.0.dist-info/METADATA +251 -0
- sentinel_codegraph-0.3.0.dist-info/RECORD +23 -0
- sentinel_codegraph-0.3.0.dist-info/WHEEL +4 -0
- sentinel_codegraph-0.3.0.dist-info/entry_points.txt +2 -0
codegraph/tree.py
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
"""Hierarchy-tree snapshot + renderer for the code graph.
|
|
2
|
+
|
|
3
|
+
:func:`load_snapshot` reads one root's rows; :func:`render_tree` turns
|
|
4
|
+
them into a nested unicode tree (pure, no I/O — unit-testable).
|
|
5
|
+
``contains`` edges drive the nesting (file → def, class → method,
|
|
6
|
+
function → nested def); ``calls`` edges (caller → callee) render as a
|
|
7
|
+
``calls (N)`` subgroup under each function; ``imports`` edges render as
|
|
8
|
+
a leaf group carrying each edge's ``target_module``.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import enum
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from typing import TYPE_CHECKING
|
|
16
|
+
|
|
17
|
+
from codegraph.models import Edge, EdgeKind, Node, NodeKind
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from codegraph.graph_store import LadybugStore
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True, slots=True)
|
|
24
|
+
class GraphSnapshot:
|
|
25
|
+
"""All rows of one indexed root, ready to render."""
|
|
26
|
+
|
|
27
|
+
root: str
|
|
28
|
+
nodes: tuple[Node, ...]
|
|
29
|
+
edges: tuple[Edge, ...]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
async def load_snapshot(store: LadybugStore, root: str) -> GraphSnapshot:
|
|
33
|
+
"""Load every node/edge of ``root`` into a :class:`GraphSnapshot`."""
|
|
34
|
+
nodes: list[Node] = await store.list_nodes(root=root)
|
|
35
|
+
edges: list[Edge] = await store.list_edges(root=root)
|
|
36
|
+
return GraphSnapshot(root=root, nodes=tuple(nodes), edges=tuple(edges))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def kind_label(kind: NodeKind | EdgeKind | str) -> str:
|
|
40
|
+
"""Normalise a kind to its plain lowercase value (``file``, …)."""
|
|
41
|
+
if isinstance(kind, enum.Enum):
|
|
42
|
+
return str(kind.value).lower()
|
|
43
|
+
text: str = str(kind)
|
|
44
|
+
if "." in text:
|
|
45
|
+
text = text.rsplit(".", 1)[-1]
|
|
46
|
+
return text.lower()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _span(node: Node) -> str:
|
|
50
|
+
"""Format a node's line range as ``(Lstart-Lend)``."""
|
|
51
|
+
return f"(L{node.start_line}-L{node.end_line})"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def render_tree(snapshot: GraphSnapshot, *, use_unicode: bool = True) -> str:
|
|
55
|
+
"""Render ``snapshot`` as a nested hierarchy tree.
|
|
56
|
+
|
|
57
|
+
``use_unicode=False`` selects an ASCII-safe glyph set for consoles
|
|
58
|
+
whose encoding cannot represent box-drawing characters (e.g. the
|
|
59
|
+
Windows cp1252 console).
|
|
60
|
+
"""
|
|
61
|
+
branch_last: str = "└── " if use_unicode else "`-- "
|
|
62
|
+
branch_mid: str = "├── " if use_unicode else "|-- "
|
|
63
|
+
stem_last: str = " "
|
|
64
|
+
stem_mid: str = "│ " if use_unicode else "| "
|
|
65
|
+
files: list[Node] = sorted(
|
|
66
|
+
(n for n in snapshot.nodes if kind_label(n.kind) == "file"),
|
|
67
|
+
key=lambda n: n.file_path,
|
|
68
|
+
)
|
|
69
|
+
by_id: dict[str, Node] = {n.id: n for n in snapshot.nodes}
|
|
70
|
+
contains: dict[str, list[str]] = {}
|
|
71
|
+
calls: dict[str, list[Edge]] = {}
|
|
72
|
+
import_targets: dict[str, str | None] = {}
|
|
73
|
+
for edge in snapshot.edges:
|
|
74
|
+
label: str = kind_label(edge.kind)
|
|
75
|
+
if label == "contains":
|
|
76
|
+
contains.setdefault(edge.src_id, []).append(edge.dst_id)
|
|
77
|
+
elif label == "calls":
|
|
78
|
+
calls.setdefault(edge.src_id, []).append(edge)
|
|
79
|
+
elif label == "imports":
|
|
80
|
+
contains.setdefault(edge.src_id, []).append(edge.dst_id)
|
|
81
|
+
import_targets[edge.dst_id] = edge.target_module
|
|
82
|
+
|
|
83
|
+
def ordered(node_ids: list[str]) -> list[Node]:
|
|
84
|
+
"""Resolve ids to nodes, ordered by start line."""
|
|
85
|
+
resolved: list[Node] = [by_id[i] for i in node_ids if i in by_id]
|
|
86
|
+
resolved.sort(key=lambda n: (n.start_line, n.name))
|
|
87
|
+
return resolved
|
|
88
|
+
|
|
89
|
+
def _callee_sort_key(call: Edge) -> tuple[int, int, int, str]:
|
|
90
|
+
"""Order callees by implementation order: call-site line first.
|
|
91
|
+
|
|
92
|
+
Edges without a site line (non-Python path, older rows) fall
|
|
93
|
+
back to the callee def's start line, then name — never worse
|
|
94
|
+
than the old alphabetical order.
|
|
95
|
+
"""
|
|
96
|
+
node: Node | None = by_id.get(call.dst_id)
|
|
97
|
+
def_start: int = node.start_line if node is not None else 1 << 30
|
|
98
|
+
if call.site_line is not None:
|
|
99
|
+
return (0, call.site_line, def_start, "")
|
|
100
|
+
return (1, def_start, 1 << 30, node.name if node is not None else call.dst_id)
|
|
101
|
+
|
|
102
|
+
def ordered_callees(call_edges: list[Edge]) -> list[str]:
|
|
103
|
+
"""Render each calls dst in implementation order (dsts are real ids).
|
|
104
|
+
|
|
105
|
+
Returns display lines. A dst missing from the snapshot renders
|
|
106
|
+
as ``<id> (missing)`` (defensive — the build never emits
|
|
107
|
+
dangling edges).
|
|
108
|
+
"""
|
|
109
|
+
lines: list[str] = []
|
|
110
|
+
for call in sorted(call_edges, key=_callee_sort_key):
|
|
111
|
+
dst_id: str = call.dst_id
|
|
112
|
+
node: Node | None = by_id.get(dst_id)
|
|
113
|
+
if node is not None:
|
|
114
|
+
lines.append(f"{kind_label(node.kind)} {node.name} {_span(node)}")
|
|
115
|
+
else:
|
|
116
|
+
lines.append(f"{dst_id} (missing)")
|
|
117
|
+
return lines
|
|
118
|
+
|
|
119
|
+
def render_children(prefix: str, node: Node, lines: list[str]) -> None:
|
|
120
|
+
"""Render the subtree under one def node.
|
|
121
|
+
|
|
122
|
+
Classes and interfaces show methods as leaves; functions and
|
|
123
|
+
methods show nested classes as nested blocks plus a
|
|
124
|
+
``calls (N)`` subgroup. Type aliases are leaves.
|
|
125
|
+
Every node appears exactly once.
|
|
126
|
+
"""
|
|
127
|
+
label: str = kind_label(node.kind)
|
|
128
|
+
if label in ("class", "interface"):
|
|
129
|
+
methods: list[Node] = [
|
|
130
|
+
m
|
|
131
|
+
for m in ordered(contains.get(node.id, []))
|
|
132
|
+
if kind_label(m.kind) == "method"
|
|
133
|
+
]
|
|
134
|
+
for method_index, method in enumerate(methods):
|
|
135
|
+
last: bool = method_index == len(methods) - 1
|
|
136
|
+
branch: str = branch_last if last else branch_mid
|
|
137
|
+
lines.append(f"{prefix}{branch}method {method.name} {_span(method)}")
|
|
138
|
+
render_children_calls_only(f"{prefix}{stem_last if last else stem_mid}", method, lines)
|
|
139
|
+
elif label in ("function", "method"):
|
|
140
|
+
nested: list[Node] = [
|
|
141
|
+
n
|
|
142
|
+
for n in ordered(contains.get(node.id, []))
|
|
143
|
+
if kind_label(n.kind) == "class"
|
|
144
|
+
]
|
|
145
|
+
callee_lines: list[str] = ordered_callees(calls.get(node.id, []))
|
|
146
|
+
# Each block is (header, nested node XOR leaf lines).
|
|
147
|
+
blocks: list[tuple[str, Node | None, list[str]]] = [
|
|
148
|
+
(f"class {n.name} {_span(n)}", n, []) for n in nested
|
|
149
|
+
]
|
|
150
|
+
if callee_lines:
|
|
151
|
+
blocks.append((f"calls ({len(callee_lines)})", None, callee_lines))
|
|
152
|
+
for block_index, (header, nested_node, leaves) in enumerate(blocks):
|
|
153
|
+
last_block: bool = block_index == len(blocks) - 1
|
|
154
|
+
branch = branch_last if last_block else branch_mid
|
|
155
|
+
lines.append(f"{prefix}{branch}{header}")
|
|
156
|
+
stem: str = stem_last if last_block else stem_mid
|
|
157
|
+
if nested_node is not None:
|
|
158
|
+
render_children(f"{prefix}{stem}", nested_node, lines)
|
|
159
|
+
else:
|
|
160
|
+
for leaf_index, leaf in enumerate(leaves):
|
|
161
|
+
leaf_branch: str = (
|
|
162
|
+
branch_last
|
|
163
|
+
if leaf_index == len(leaves) - 1
|
|
164
|
+
else branch_mid
|
|
165
|
+
)
|
|
166
|
+
lines.append(f"{prefix}{stem}{leaf_branch}{leaf}")
|
|
167
|
+
|
|
168
|
+
def render_children_calls_only(prefix: str, node: Node, lines: list[str]) -> None:
|
|
169
|
+
"""Render only the ``calls`` subgroup under a method leaf."""
|
|
170
|
+
callee_lines: list[str] = ordered_callees(calls.get(node.id, []))
|
|
171
|
+
if not callee_lines:
|
|
172
|
+
return
|
|
173
|
+
lines.append(f"{prefix}{branch_last}calls ({len(callee_lines)})")
|
|
174
|
+
for leaf_index, leaf in enumerate(callee_lines):
|
|
175
|
+
leaf_branch = branch_last if leaf_index == len(callee_lines) - 1 else branch_mid
|
|
176
|
+
lines.append(f"{prefix}{stem_last}{leaf_branch}{leaf}")
|
|
177
|
+
|
|
178
|
+
lines: list[str] = [
|
|
179
|
+
f"root: {snapshot.root} "
|
|
180
|
+
f"(files={len(files)} nodes={len(snapshot.nodes)} "
|
|
181
|
+
f"edges={len(snapshot.edges)})"
|
|
182
|
+
]
|
|
183
|
+
for file_index, file_node in enumerate(files):
|
|
184
|
+
if file_index > 0:
|
|
185
|
+
lines.append("")
|
|
186
|
+
lines.append(
|
|
187
|
+
f"file {file_node.file_path} [{file_node.language}] {_span(file_node)}"
|
|
188
|
+
)
|
|
189
|
+
contained: list[Node] = ordered(contains.get(file_node.id, []))
|
|
190
|
+
classes: list[Node] = [n for n in contained if kind_label(n.kind) == "class"]
|
|
191
|
+
interfaces: list[Node] = [
|
|
192
|
+
n for n in contained if kind_label(n.kind) == "interface"
|
|
193
|
+
]
|
|
194
|
+
top_functions: list[Node] = [
|
|
195
|
+
n for n in contained if kind_label(n.kind) == "function"
|
|
196
|
+
]
|
|
197
|
+
top_methods: list[Node] = [
|
|
198
|
+
n for n in contained if kind_label(n.kind) == "method"
|
|
199
|
+
]
|
|
200
|
+
type_nodes: list[Node] = [n for n in contained if kind_label(n.kind) == "type"]
|
|
201
|
+
imports: list[Node] = [
|
|
202
|
+
n for n in contained if kind_label(n.kind) == "import"
|
|
203
|
+
]
|
|
204
|
+
def_blocks: list[Node] = (
|
|
205
|
+
classes + interfaces + top_functions + top_methods + type_nodes
|
|
206
|
+
)
|
|
207
|
+
has_imports: bool = len(imports) > 0
|
|
208
|
+
for block_index, def_node in enumerate(def_blocks):
|
|
209
|
+
last_block: bool = block_index == len(def_blocks) - 1 and not has_imports
|
|
210
|
+
branch = branch_last if last_block else branch_mid
|
|
211
|
+
lines.append(
|
|
212
|
+
f"{branch}{kind_label(def_node.kind)} {def_node.name} "
|
|
213
|
+
f"{_span(def_node)}"
|
|
214
|
+
)
|
|
215
|
+
stem = stem_last if last_block else stem_mid
|
|
216
|
+
render_children(stem, def_node, lines)
|
|
217
|
+
if imports:
|
|
218
|
+
lines.append(f"{branch_last}imports ({len(imports)})")
|
|
219
|
+
for leaf_index, node in enumerate(imports):
|
|
220
|
+
leaf_branch = (
|
|
221
|
+
branch_last if leaf_index == len(imports) - 1 else branch_mid
|
|
222
|
+
)
|
|
223
|
+
lines.append(
|
|
224
|
+
f"{stem_last}{leaf_branch}{node.name} -> "
|
|
225
|
+
f"{import_targets.get(node.id) or '?'} (L{node.start_line})"
|
|
226
|
+
)
|
|
227
|
+
return "\n".join(lines)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
__all__ = ["GraphSnapshot", "kind_label", "load_snapshot", "render_tree"]
|
codegraph/walk.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""File discovery for the CLI.
|
|
2
|
+
|
|
3
|
+
Maps extensions to tree-sitter language names, prunes noise
|
|
4
|
+
directories, and skips oversized / undecodable / empty files.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import os
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
EXT_TO_LANGUAGE: dict[str, str] = {
|
|
15
|
+
".py": "python",
|
|
16
|
+
".ts": "typescript",
|
|
17
|
+
".tsx": "typescript",
|
|
18
|
+
".js": "javascript",
|
|
19
|
+
".jsx": "javascript",
|
|
20
|
+
".go": "go",
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
SKIP_DIRS: frozenset[str] = frozenset(
|
|
24
|
+
{
|
|
25
|
+
".git",
|
|
26
|
+
".github",
|
|
27
|
+
".idea",
|
|
28
|
+
".vscode",
|
|
29
|
+
".next",
|
|
30
|
+
"__pycache__",
|
|
31
|
+
"build",
|
|
32
|
+
"dist",
|
|
33
|
+
"node_modules",
|
|
34
|
+
"target",
|
|
35
|
+
"venv",
|
|
36
|
+
".venv",
|
|
37
|
+
}
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
MAX_FILE_BYTES: int = 20 * 1024 * 1024
|
|
41
|
+
"""Skip files larger than this — the graph is for source, not dumps."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True, slots=True)
|
|
45
|
+
class DiscoveredFile:
|
|
46
|
+
"""A supported source file found under the scan root."""
|
|
47
|
+
|
|
48
|
+
abs_path: Path
|
|
49
|
+
rel_path: str
|
|
50
|
+
language: str
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def normalise_root(path: Path) -> Path:
|
|
54
|
+
"""Return the resolved absolute scan root."""
|
|
55
|
+
return path.expanduser().resolve()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def to_rel_posix(abs_path: Path, root: Path) -> str:
|
|
59
|
+
"""Return the ``/``-separated path of ``abs_path`` relative to ``root``."""
|
|
60
|
+
return abs_path.relative_to(root).as_posix()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def discover_files(target: Path, root: Path) -> list[DiscoveredFile]:
|
|
64
|
+
"""List supported files for ``target`` (file or dir) under ``root``.
|
|
65
|
+
|
|
66
|
+
Directory walks are pruned at ``SKIP_DIRS`` and sorted for stable
|
|
67
|
+
output. Files that are unsupported, oversized, or empty are skipped.
|
|
68
|
+
"""
|
|
69
|
+
candidates: list[Path] = []
|
|
70
|
+
if target.is_file():
|
|
71
|
+
candidates = [target]
|
|
72
|
+
else:
|
|
73
|
+
for parent, dirs, filenames in os.walk(target):
|
|
74
|
+
dirs[:] = sorted(d for d in dirs if d not in SKIP_DIRS)
|
|
75
|
+
for filename in sorted(filenames):
|
|
76
|
+
candidates.append(Path(parent) / filename)
|
|
77
|
+
|
|
78
|
+
found: list[DiscoveredFile] = []
|
|
79
|
+
for abs_path in candidates:
|
|
80
|
+
language: str | None = EXT_TO_LANGUAGE.get(abs_path.suffix.lower())
|
|
81
|
+
if language is None:
|
|
82
|
+
continue
|
|
83
|
+
try:
|
|
84
|
+
if abs_path.stat().st_size > MAX_FILE_BYTES:
|
|
85
|
+
continue
|
|
86
|
+
except OSError:
|
|
87
|
+
continue
|
|
88
|
+
try:
|
|
89
|
+
if not abs_path.is_relative_to(root):
|
|
90
|
+
continue
|
|
91
|
+
rel: str = to_rel_posix(abs_path.resolve(), root)
|
|
92
|
+
except (OSError, ValueError):
|
|
93
|
+
continue
|
|
94
|
+
found.append(
|
|
95
|
+
DiscoveredFile(abs_path=abs_path, rel_path=rel, language=language)
|
|
96
|
+
)
|
|
97
|
+
return found
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
async def adiscover_files(target: Path, root: Path) -> list[DiscoveredFile]:
|
|
101
|
+
"""Async wrapper over :func:`discover_files` (runs off the loop)."""
|
|
102
|
+
return await asyncio.to_thread(discover_files, target, root)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
__all__ = [
|
|
106
|
+
"DiscoveredFile",
|
|
107
|
+
"EXT_TO_LANGUAGE",
|
|
108
|
+
"MAX_FILE_BYTES",
|
|
109
|
+
"SKIP_DIRS",
|
|
110
|
+
"adiscover_files",
|
|
111
|
+
"discover_files",
|
|
112
|
+
"normalise_root",
|
|
113
|
+
"to_rel_posix",
|
|
114
|
+
]
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: sentinel-codegraph
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: Sentinel code graph CLI: tree-sitter nodes/edges into SQL
|
|
5
|
+
Author: Sentinel
|
|
6
|
+
Requires-Python: >=3.13
|
|
7
|
+
Requires-Dist: ladybug==0.19.1
|
|
8
|
+
Requires-Dist: tree-sitter-language-pack>=1.20.0
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
|
|
11
|
+
# Sentinel Code Graph CLI
|
|
12
|
+
|
|
13
|
+
Async CLI that scans a directory or file, extracts structural nodes
|
|
14
|
+
(files, classes, functions, methods, interfaces, types, imports) and
|
|
15
|
+
edges (contains, imports, calls) with raw tree-sitter, and stores them
|
|
16
|
+
in Ladybug (embedded property graph, zero setup).
|
|
17
|
+
|
|
18
|
+
- Default database is a known-location Ladybug file
|
|
19
|
+
(`~/.codegraph/graph.lbdb`), so query time never guesses where the
|
|
20
|
+
index lives. Pass `--db` to any command to override it.
|
|
21
|
+
- `--db :memory:` indexes ephemerally (lost when the process exits).
|
|
22
|
+
- Scope: **Python, TypeScript/JavaScript, Go**. (`queries.py` stays on
|
|
23
|
+
disk, unwired.)
|
|
24
|
+
- One database holds exactly one index: `--overwrite` flushes the
|
|
25
|
+
whole db first, so re-indexing is idempotent.
|
|
26
|
+
- `--no-persist` dry-runs the pipeline (parse → nodes → edges → print,
|
|
27
|
+
no DB writes).
|
|
28
|
+
|
|
29
|
+
## Install
|
|
30
|
+
|
|
31
|
+
From the repo root (workspace member, latest pinned deps in `uv.lock`):
|
|
32
|
+
|
|
33
|
+
```powershell
|
|
34
|
+
uv sync
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Usage
|
|
38
|
+
|
|
39
|
+
```powershell
|
|
40
|
+
# index a tree into the default DB (re-runnable; --overwrite flushes first)
|
|
41
|
+
uv run --package codegraph python -m codegraph.cli index ./packages/api/src --overwrite
|
|
42
|
+
|
|
43
|
+
# index a single file (root = its parent dir; must be a supported language)
|
|
44
|
+
uv run --package codegraph python -m codegraph.cli index ./packages/api/main.py
|
|
45
|
+
|
|
46
|
+
# inspect the database
|
|
47
|
+
uv run --package codegraph python -m codegraph.cli stats
|
|
48
|
+
|
|
49
|
+
# index and print the hierarchy tree of the indexed root
|
|
50
|
+
uv run --package codegraph python -m codegraph.cli index ./src --output tree
|
|
51
|
+
|
|
52
|
+
# dump every collected node (kind, lines, parent, children, callees)
|
|
53
|
+
uv run --package codegraph python -m codegraph.cli index ./src --output nodes
|
|
54
|
+
|
|
55
|
+
# list every calls edge (caller -> callee, implementation order)
|
|
56
|
+
uv run --package codegraph python -m codegraph.cli index ./src --output calls
|
|
57
|
+
|
|
58
|
+
# dry-run: print without persisting
|
|
59
|
+
uv run --package codegraph python -m codegraph.cli index ./src --no-persist --output tree
|
|
60
|
+
|
|
61
|
+
# Ephemeral index (no file written)
|
|
62
|
+
uv run --package codegraph python -m codegraph.cli index ./src --db :memory: --output tree
|
|
63
|
+
|
|
64
|
+
# explore the index (read-only; works from anywhere — default DB is known)
|
|
65
|
+
uv run --package codegraph python -m codegraph.cli query overview
|
|
66
|
+
uv run --package codegraph python -m codegraph.cli query files
|
|
67
|
+
uv run --package codegraph python -m codegraph.cli query search --name reviewWorkflowV2
|
|
68
|
+
uv run --package codegraph python -m codegraph.cli query callees --name reviewWorkflowV2 --json
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Flow
|
|
72
|
+
|
|
73
|
+
`amain` parses args, then three linear stages in `pipeline.py`:
|
|
74
|
+
|
|
75
|
+
```
|
|
76
|
+
read(target) -> build_graph(root, items) -> out(db, root, graph, ...)
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
- **read** — discover (`walk`: suffix → language, prune noise dirs,
|
|
80
|
+
skip oversize/undecodable) + read off the loop → `ScannedSources(root,
|
|
81
|
+
items)`. I/O.
|
|
82
|
+
- **build_graph** — pure, two passes: (1) collect — `parse_file_input`
|
|
83
|
+
per file (blank source or unsupported language → skip, counted)
|
|
84
|
+
into nodes + `contains` / `imports` plus buffered call sites; (2)
|
|
85
|
+
link — `resolve_call_edges` joins sites against the `(file, name)`
|
|
86
|
+
definition registry + per-file import maps (alias-aware, joined on
|
|
87
|
+
a single candidate: the defining-module original when the import
|
|
88
|
+
carries one, else the bound name; Go dot-imported files are a
|
|
89
|
+
fallback) into `calls` with real node ids, dropping unresolvable
|
|
90
|
+
sites. Merge → `BuiltGraph(root, files, skipped, nodes, edges)`.
|
|
91
|
+
No I/O, no DB.
|
|
92
|
+
- **out** — persist (`create_all`, `clear_all` when `--overwrite`,
|
|
93
|
+
`add_all`) then print (`summary` | `tree` | `nodes` | `calls`) →
|
|
94
|
+
`IndexResult`. I/O.
|
|
95
|
+
|
|
96
|
+
`cli.py` holds only arg parsing + `amain` wiring + `run_stats`;
|
|
97
|
+
`__main__.py` calls `cli.main()`.
|
|
98
|
+
|
|
99
|
+
## Python emitter
|
|
100
|
+
|
|
101
|
+
Each Python file is walked once (`collect_python_file`):
|
|
102
|
+
`Node` + `Edge(CONTAINS)` with `base:name:start:end` ids for defs,
|
|
103
|
+
`Node(IMPORT)` + `Edge(IMPORTS)` per imported name (keeping the
|
|
104
|
+
`as`-alias original, e.g. `from utils import helper as h` records bound
|
|
105
|
+
`h` + original `helper`), and bare-name calls buffered unresolved with
|
|
106
|
+
their call-site line. The link phase then joins each site — same-file
|
|
107
|
+
hit first, else the import map's resolved file + original name — into
|
|
108
|
+
`calls` edges with real node ids. Absolute imports tolerate a
|
|
109
|
+
root-relative prefix on indexed paths (`src/app/…` satisfies
|
|
110
|
+
`import app.…`). Builtins, stdlib, third-party, star-import calls,
|
|
111
|
+
attribute calls (`obj.method(…)`, `self.x(…)`), and module-level call
|
|
112
|
+
sites yield no edges. Decorator applications (`@retry`,
|
|
113
|
+
`@with_logging(…)`) buffer as call sites on the decorated def/class
|
|
114
|
+
with the @-line and resolve like ordinary calls (attribute decorators
|
|
115
|
+
such as `@app.get(…)` are skipped). One edge per caller → callee pair,
|
|
116
|
+
each stamped with its call-site line. Calls render in implementation
|
|
117
|
+
order, not alphabetical.
|
|
118
|
+
|
|
119
|
+
## TypeScript / JavaScript collector
|
|
120
|
+
|
|
121
|
+
Each TS/JS file is walked once (`collect_ts_file`): classes,
|
|
122
|
+
functions, methods, interfaces, type aliases, and arrow-bound consts
|
|
123
|
+
(`export` wrappers are transparent) → `Node` + `Edge(CONTAINS)`;
|
|
124
|
+
imports keep alias originals (`import {a as b}` → bound `b` +
|
|
125
|
+
original `a`; default imports join on the bound name); bare calls and
|
|
126
|
+
`new C()` constructions buffer unresolved. Relative specifiers resolve
|
|
127
|
+
with extension + `/index` fallbacks; bare (npm) specifiers never
|
|
128
|
+
resolve. Member calls (`obj.m()`), builtins, and module-level sites
|
|
129
|
+
yield no edges.
|
|
130
|
+
|
|
131
|
+
## Go collector
|
|
132
|
+
|
|
133
|
+
Each Go file is walked once (`collect_go_file`): funcs, methods
|
|
134
|
+
(reparented to their receiver struct when same-file), struct types →
|
|
135
|
+
`class`, interface types → `interface` (with `method_elem` children as
|
|
136
|
+
methods), other named types → `type`. Blank imports bind `*`, dot
|
|
137
|
+
imports bind `.` (their target files are searched for otherwise
|
|
138
|
+
unresolved bare sites). Import tails match indexed `.go` stems;
|
|
139
|
+
selector calls (`pkg.Fn()`), builtins, and package-level sites yield
|
|
140
|
+
no edges.
|
|
141
|
+
|
|
142
|
+
## Schema (Ladybug)
|
|
143
|
+
|
|
144
|
+
- `CodeNode`: `id (PK), root, file_path, kind (file|class|function|method|interface|type|import),
|
|
145
|
+
name, language, start_line, end_line, parent_id, is_placeholder`
|
|
146
|
+
- `Contains` / `Imports` / `Calls`: rels between `CodeNode` rows
|
|
147
|
+
(`target_module` on `Imports`, `site_line` on `Calls`).
|
|
148
|
+
|
|
149
|
+
`site_line` is the 1-based call-site line inside the caller (`Calls`
|
|
150
|
+
rels only; `NULL` = unknown). Callees sort by it.
|
|
151
|
+
|
|
152
|
+
## Node ids (all languages)
|
|
153
|
+
|
|
154
|
+
- file node: `base` (path anchored at the CLI root, e.g. `workflows/review.py`)
|
|
155
|
+
- def node: `base:name:start:end` (e.g. `workflows/review.py:helper:10:15`)
|
|
156
|
+
- import node: `base:import:<name>:<line>`
|
|
157
|
+
|
|
158
|
+
Every stored `Calls` edge points at real node ids — unresolvable call
|
|
159
|
+
sites are dropped at build time, so no placeholder rows exist
|
|
160
|
+
(`is_placeholder` stays on the schema for old databases only).
|
|
161
|
+
Querier's check:
|
|
162
|
+
|
|
163
|
+
```cypher
|
|
164
|
+
// 1. file's nodes (find the caller)
|
|
165
|
+
MATCH (f:CodeNode {id: '<file>'})-[:Contains]->(n) RETURN n;
|
|
166
|
+
// 2. caller's callees, in implementation order
|
|
167
|
+
MATCH (c:CodeNode {id: '<caller id>'})-[e:Calls]->(d) RETURN d ORDER BY e.site_line;
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
Tables are created with `create_all` (`IF NOT EXISTS`): after a
|
|
171
|
+
schema change, delete the `.lbdb` file and re-index.
|
|
172
|
+
|
|
173
|
+
## Querying (agent ladder)
|
|
174
|
+
|
|
175
|
+
The `query` verbs are read-only and built for agents that don't know
|
|
176
|
+
node id shapes — every step returns the ids the next step needs:
|
|
177
|
+
|
|
178
|
+
1. `query overview` — what's indexed (counts by kind/edge/language).
|
|
179
|
+
2. `query files` — rel paths (a file node id *is* its rel path).
|
|
180
|
+
3. `query search --name <fragment> [--kind …] [--file …] [--limit N]` —
|
|
181
|
+
substring-match def names → full rows with ids. Zero hits and
|
|
182
|
+
truncation (`truncated: true`) tell the agent to rephrase/narrow.
|
|
183
|
+
4. `query node|callees|callers|children --id <id>` (or `--name` exact +
|
|
184
|
+
optional `--file`; ambiguity is an error listing hits) and
|
|
185
|
+
`query imports --file <rel>` — drill down; new names chain back
|
|
186
|
+
to `search`.
|
|
187
|
+
|
|
188
|
+
`--root` narrows to one indexed root (default: all), `--json` emits
|
|
189
|
+
the stable chaining envelope
|
|
190
|
+
(`{root, verb, count, truncated, items[]}` with
|
|
191
|
+
`{id, kind, name, file_path, language, start_line, end_line,
|
|
192
|
+
parent_id}` plus `site_line` on calls items and `target_module` on
|
|
193
|
+
imports items). Querying a database that was never indexed exits 1
|
|
194
|
+
with a hint instead of an empty result — and read paths never create
|
|
195
|
+
files or directories (only `index` persistence creates
|
|
196
|
+
`~/.codegraph/`).
|
|
197
|
+
|
|
198
|
+
## Module map
|
|
199
|
+
|
|
200
|
+
- `cli.py` — arg parsing, `amain` wiring (`read → build_graph → out`), `main`, `run_stats`, `run_query`
|
|
201
|
+
- `query.py` — pure exploration shaping: `search_nodes`, `exact_matches`, JSON envelope, human rendering
|
|
202
|
+
- `pipeline.py` — `read` / `build_graph` / `out`, `ScannedSources` / `BuiltGraph` / `IndexResult`, `OutputMode`, `print_tree` / `print_nodes` / `print_calls`
|
|
203
|
+
- `parser/__init__.py` — barrel: re-exports only, no logic
|
|
204
|
+
- `parser/rows.py` — `FileRows`, `build_file_rows`, `build_python_file_rows`
|
|
205
|
+
- `parser/links.py` — `ImportEntry`, `build_file_import_map`, `build_dot_import_targets`, `resolve_call_edges` (+ per-language module-specifier resolvers)
|
|
206
|
+
- `parser/lang_python.py` — Python collect phase (`collect_python_file`)
|
|
207
|
+
- `parser/lang_typescript.py` — TS/JS collect phase (`collect_ts_file`)
|
|
208
|
+
- `parser/lang_go.py` — Go collect phase (`collect_go_file`)
|
|
209
|
+
- `parser/queries.py` — kept, unwired
|
|
210
|
+
- `parser/base.py` — `ParsedFile` / `ParsedDefinition` / `ParsedImport` / `ParsedCall` IR types
|
|
211
|
+
- `parser/raw_core.py` — tree-sitter `parse`, `walk`, `span`, text helpers
|
|
212
|
+
- `models.py` — `Node` / `Edge` dataclasses (+ `NodeKind`, `EdgeKind`)
|
|
213
|
+
- `graph_store.py` — `LadybugStore` (UNWIND ingest, `clear_all`, `add_all`, count/list queries, narrow `get_node` / `callees` / `callers` / `children` / `file_imports` / `list_files` reads)
|
|
214
|
+
- `tree.py` — `GraphSnapshot`, `render_tree` (nesting by `contains`, calls in `site_line` order)
|
|
215
|
+
- `walk.py` — suffix → language discovery, noise-dir pruning
|
|
216
|
+
- `config.py` — `DEFAULT_DB` (`~/.codegraph/graph.lbdb`) + `--db` path resolution: file path or `:memory:` (`ResolvedDb`)
|
|
217
|
+
|
|
218
|
+
## Design notes
|
|
219
|
+
|
|
220
|
+
- Pipeline is functional: `read -> build_graph -> out`. Extractors and
|
|
221
|
+
builders are pure; I/O lives only at the edge (discover + read,
|
|
222
|
+
persist, print).
|
|
223
|
+
- Build output is a simple flat graph: `BuiltGraph` carries merged
|
|
224
|
+
`nodes` + `edges` tuples only. Every consumer (persist, snapshot,
|
|
225
|
+
printers) derives what it needs from those two lists.
|
|
226
|
+
- Graph values are frozen dataclasses holding tuples — immutable
|
|
227
|
+
snapshots, safe to share between build and out.
|
|
228
|
+
- Parsing uses raw `tree_sitter_language_pack.get_parser().parse()`.
|
|
229
|
+
Import *names* are recovered from the statement source text in pure
|
|
230
|
+
Python.
|
|
231
|
+
- Parsing runs on the event-loop thread (tree-sitter objects are not
|
|
232
|
+
thread-safe); only file I/O goes through `asyncio.to_thread`.
|
|
233
|
+
- Ladybug is embedded: one `AsyncConnection` per store, and `out()`
|
|
234
|
+
holds a single store from persist through the snapshot reads, so
|
|
235
|
+
`:memory:` databases stay alive for the whole call.
|
|
236
|
+
- Calls are bare-name only (`name(…)`), alias-aware via the import
|
|
237
|
+
map (`from utils import helper as h` + `h()` resolves to `helper`;
|
|
238
|
+
TS `import {a as b}` + `b()` resolves to `a`; TS default imports
|
|
239
|
+
join on the bound name; Go dot-imported files are a fallback);
|
|
240
|
+
`new C()` in TS counts as a call. Attribute / member calls
|
|
241
|
+
(`obj.method()`, `self.x()`, `pkg.Fn()`), builtins, stdlib,
|
|
242
|
+
third-party, star-import calls, and module-level call sites yield
|
|
243
|
+
no edges.
|
|
244
|
+
- Files with unsupported suffixes are discovered, then skipped
|
|
245
|
+
(`(N skipped)` in the summary).
|
|
246
|
+
|
|
247
|
+
## Tests
|
|
248
|
+
|
|
249
|
+
```powershell
|
|
250
|
+
uv run --package codegraph pytest tests
|
|
251
|
+
```
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
codegraph/__init__.py,sha256=l_Bq2tU9-2aT9I-e0T312qr7WXUvIeWvNY-X2YJIzVo,144
|
|
2
|
+
codegraph/__main__.py,sha256=Psd_CiHcSc6H-s0rvJkRAisDCtSEiZBtzJ_oRHRhqXg,130
|
|
3
|
+
codegraph/cli.py,sha256=3-PCb2bUt9K88C5XnmV5j6zZujI0OCdDg3JJfnLfUh4,15952
|
|
4
|
+
codegraph/config.py,sha256=wY7VNPA4HPl54VvipcM6k3K3oa997zqc3767WhftlfU,1655
|
|
5
|
+
codegraph/graph_store.py,sha256=7t8l0UWVDqxpHEkhQ0Upbln9IV3MAUw0gvPUNobGhLI,18028
|
|
6
|
+
codegraph/models.py,sha256=4eVVjfxDx5azV1glE9zMNpYOVPXDFCN-MURyXfZBG8k,2390
|
|
7
|
+
codegraph/pipeline.py,sha256=TgM9W5yp-Hj8DM0Qy-LGTur3Yan99Ri1yqN3StwZEZs,12413
|
|
8
|
+
codegraph/query.py,sha256=FKOC-kDCDqHZ6mNxDk5PHcBok68Gp5D9kFy_OxyNnHs,4121
|
|
9
|
+
codegraph/tree.py,sha256=7QLvS5JEMm6NjAqf3te2iSqnKu86e_oHZ5XZG4hXgu8,9933
|
|
10
|
+
codegraph/walk.py,sha256=nNFcPxqFvC5TSseqToDNBLbfBLjTcW7qj--T3G16uIw,3038
|
|
11
|
+
codegraph/parser/__init__.py,sha256=lXBQLLiV6RPeKIuNrHH0yyCByUzBnagf2YfO88UBOM4,770
|
|
12
|
+
codegraph/parser/base.py,sha256=kVYxqGqy0pA8qISjg9TUEppJTQP7LHMKDaqdEMm_RPY,3633
|
|
13
|
+
codegraph/parser/lang_go.py,sha256=1ISMiH-B9Bg_FlwcG8Qd1rqs_zfieGrUIz9xWfJ9HdM,18197
|
|
14
|
+
codegraph/parser/lang_python.py,sha256=neG9DB30-X7DHHnWdTTu-pyNEBTHMcKTQu5vtXQsF_U,16833
|
|
15
|
+
codegraph/parser/lang_typescript.py,sha256=TwXqN_-Xu1bRRfUyEc70thdHKCwSvbNNWkqbJobMxxM,19550
|
|
16
|
+
codegraph/parser/links.py,sha256=TruNiQ6Br02rwjVJD6X2vyS6EZOYNJpAFBFg0jEF5_0,12050
|
|
17
|
+
codegraph/parser/queries.py,sha256=B9ojYeQMmuhWWkMuF3JpcPNeOZeYDZBGPstzmhkjqSI,4052
|
|
18
|
+
codegraph/parser/raw_core.py,sha256=i7RgDV06k14quLlA-Kfx8BQpo3kaDsN6a3Iwtkq7AJA,2023
|
|
19
|
+
codegraph/parser/rows.py,sha256=FI3GZl3Zi3GPJZ4VO2Ll9HQbJ46qFicY2XsjI1ATKuM,6054
|
|
20
|
+
sentinel_codegraph-0.3.0.dist-info/METADATA,sha256=usLeWHdmDBYTyvbyk_VWNtCTIWr7nT-dxHan2n5qhpQ,11890
|
|
21
|
+
sentinel_codegraph-0.3.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
22
|
+
sentinel_codegraph-0.3.0.dist-info/entry_points.txt,sha256=3f2dJK7oR3dBzP21qRk_KuQa6Li8MVbVXeKcx3UjQ6c,49
|
|
23
|
+
sentinel_codegraph-0.3.0.dist-info/RECORD,,
|