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/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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ codegraph = codegraph.cli:main