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/pipeline.py ADDED
@@ -0,0 +1,370 @@
1
+ """Linear code-graph pipeline: read -> build -> out.
2
+
3
+ - :func:`read`: discover + read source files (I/O).
4
+ - :func:`build_graph`: collect per-file rows then link calls (pure).
5
+ - :func:`out`: persist + print (I/O).
6
+
7
+ Languages: Python, TypeScript/JavaScript, Go. Anything else discovered
8
+ is counted as skipped.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import asyncio
14
+ from collections.abc import Callable, Mapping
15
+ from dataclasses import dataclass
16
+ from pathlib import Path
17
+ from typing import Literal
18
+
19
+ from codegraph.config import ResolvedDb
20
+ from codegraph.models import Edge, Node
21
+ from codegraph.parser.lang_go import build_go_file_rows
22
+ from codegraph.parser.lang_typescript import build_ts_file_rows
23
+ from codegraph.parser.links import resolve_call_edges
24
+ from codegraph.parser.rows import FileRows, build_python_file_rows
25
+ from codegraph.graph_store import create_store
26
+ from codegraph.tree import GraphSnapshot, kind_label, load_snapshot, render_tree
27
+ from codegraph.walk import DiscoveredFile, adiscover_files, normalise_root
28
+
29
+ OutputMode = Literal["summary", "tree", "nodes", "calls"]
30
+ """``out`` modes: summary line, hierarchy tree, node dump, call list."""
31
+
32
+
33
+ @dataclass(frozen=True, slots=True)
34
+ class FileInput:
35
+ """One file's source text plus its identity (I/O edge output)."""
36
+
37
+ rel_path: str
38
+ language: str
39
+ source_text: str
40
+
41
+
42
+ @dataclass(frozen=True, slots=True)
43
+ class ScannedSources:
44
+ """One scan root plus its read source inputs."""
45
+
46
+ root: str
47
+ items: list[FileInput]
48
+
49
+
50
+ @dataclass(frozen=True, slots=True)
51
+ class BuiltGraph:
52
+ """Merged pure build output for one root: nodes + edges, nothing else."""
53
+
54
+ root: str
55
+ files: int
56
+ skipped: int
57
+ nodes: tuple[Node, ...]
58
+ edges: tuple[Edge, ...]
59
+
60
+
61
+ @dataclass(frozen=True, slots=True)
62
+ class IndexResult:
63
+ """Outcome of one :func:`out` run."""
64
+
65
+ root: str
66
+ db_label: str
67
+ files: int
68
+ nodes: int
69
+ edges: int
70
+ skipped: int
71
+
72
+
73
+ async def _read_source(path: Path) -> str:
74
+ """Read a source file off the event loop."""
75
+ return await asyncio.to_thread(path.read_text, encoding="utf-8", errors="ignore")
76
+
77
+
78
+ async def read(target: Path) -> ScannedSources:
79
+ """Discover + read every supported file under ``target``."""
80
+ root_path: Path = (
81
+ normalise_root(target) if target.is_dir() else normalise_root(target.parent)
82
+ )
83
+ root: str = root_path.as_posix()
84
+ discovered: list[DiscoveredFile] = await adiscover_files(target, root_path)
85
+ items: list[FileInput] = []
86
+ for item in discovered:
87
+ try:
88
+ text: str = await _read_source(item.abs_path)
89
+ except OSError:
90
+ continue
91
+ items.append(
92
+ FileInput(rel_path=item.rel_path, language=item.language, source_text=text)
93
+ )
94
+ return ScannedSources(root=root, items=items)
95
+
96
+
97
+ _LANGUAGE_TO_BUILDER: dict[
98
+ str, Callable[[str, str, str, Mapping[str, str]], FileRows]
99
+ ] = {
100
+ "python": build_python_file_rows,
101
+ "typescript": build_ts_file_rows,
102
+ "javascript": build_ts_file_rows,
103
+ "go": build_go_file_rows,
104
+ }
105
+ """Per-language collect builders; unknown languages skip at :func:`parse_file_input`."""
106
+
107
+
108
+ def parse_file_input(
109
+ root: str, item: FileInput, import_index: Mapping[str, str] | None = None
110
+ ) -> FileRows | None:
111
+ """Parse one file into rows. Returns None when the file is skipped.
112
+
113
+ Pure given ``item``. Blank source or a non-Python language -> skip,
114
+ counted by the caller. ``import_index`` is accepted for
115
+ signature stability and ignored: the link phase resolves modules
116
+ from the full collected file set.
117
+ """
118
+
119
+ if not item.source_text.strip():
120
+ return None
121
+ handler: Callable[[str, str, str, Mapping[str, str]], FileRows] | None = (
122
+ _LANGUAGE_TO_BUILDER.get(item.language)
123
+ )
124
+
125
+ if handler is None:
126
+ return None
127
+ return handler(root, item.rel_path, item.source_text, import_index or {})
128
+
129
+
130
+ def build_graph(root: str, items: list[FileInput]) -> BuiltGraph:
131
+ """Run the full pure build in two passes: collect, then link.
132
+
133
+ Pass 1 parses every Python file into nodes + ``contains`` /
134
+ ``imports`` edges plus buffered call sites (no ``calls`` edges).
135
+ Pass 2 joins the buffered sites against the global definition
136
+ registry + per-file import maps into ``calls`` edges with real
137
+ node ids; unresolvable sites yield no edge.
138
+ """
139
+ rows: list[FileRows] = []
140
+ skipped: int = 0
141
+ for item in items:
142
+ file_rows: FileRows | None = parse_file_input(root, item)
143
+ if file_rows is None:
144
+ skipped += 1
145
+ continue
146
+ rows.append(file_rows)
147
+ call_edges: list[Edge] = resolve_call_edges(rows, root)
148
+
149
+ return BuiltGraph(
150
+ root=root,
151
+ files=len(rows),
152
+ skipped=skipped,
153
+ nodes=tuple(n for unit in rows for n in unit.nodes),
154
+ edges=tuple(e for unit in rows for e in unit.edges) + tuple(call_edges),
155
+ )
156
+
157
+
158
+ PrintableGraph = BuiltGraph | GraphSnapshot
159
+ """Anything the printers can render: a fresh build or a stored snapshot."""
160
+
161
+
162
+ async def _snapshot_for(
163
+ db: ResolvedDb, root: str, graph: PrintableGraph | None
164
+ ) -> GraphSnapshot:
165
+ """Return the snapshot to render: in-memory build or stored rows."""
166
+ if graph is not None:
167
+ return GraphSnapshot(root=root, nodes=graph.nodes, edges=graph.edges)
168
+ store = create_store(db.path)
169
+ try:
170
+ await store.create_all()
171
+ return await load_snapshot(store, root)
172
+ finally:
173
+ await store.dispose()
174
+
175
+
176
+ def _span_of(node: Node) -> str:
177
+ """Format a node's line range as ``(Lstart-Lend)``."""
178
+ return f"(L{node.start_line}-L{node.end_line})"
179
+
180
+
181
+ async def print_tree(
182
+ db: ResolvedDb, root: str, graph: PrintableGraph | None = None
183
+ ) -> int:
184
+ """Print the hierarchy tree of one indexed ``root``."""
185
+ snapshot: GraphSnapshot = await _snapshot_for(db, root, graph)
186
+ if not snapshot.nodes:
187
+ print(f"no nodes for root {root}")
188
+ return 0
189
+ try:
190
+ print(render_tree(snapshot))
191
+ except UnicodeEncodeError:
192
+ print(render_tree(snapshot, use_unicode=False))
193
+ return 0
194
+
195
+
196
+ async def print_nodes(
197
+ db: ResolvedDb, root: str, graph: PrintableGraph | None = None
198
+ ) -> int:
199
+ """Print every collected node of ``root`` for manual analysis."""
200
+ snapshot = await _snapshot_for(db, root, graph)
201
+ if not snapshot.nodes:
202
+ print(f"no nodes for root {root}")
203
+ return 0
204
+ by_id: dict[str, Node] = {n.id: n for n in snapshot.nodes}
205
+ children: dict[str, list[str]] = {}
206
+ calls: dict[str, list[Edge]] = {}
207
+ for edge in snapshot.edges:
208
+ label: str = kind_label(edge.kind)
209
+ if label in ("contains", "imports"):
210
+ children.setdefault(edge.src_id, []).append(edge.dst_id)
211
+ elif label == "calls":
212
+ calls.setdefault(edge.src_id, []).append(edge)
213
+
214
+ def names(node_ids: list[str]) -> str:
215
+ resolved: list[str] = sorted({by_id[i].name for i in node_ids if i in by_id})
216
+ return f"[{', '.join(resolved)}]"
217
+
218
+ def _callee_order_key(call: Edge) -> tuple[int, int, str]:
219
+ """Implementation order: call-site line, then callee name."""
220
+ if call.site_line is not None:
221
+ return (0, call.site_line, "")
222
+ return (1, 1 << 30, call.dst_id)
223
+
224
+ def callee_names(call_edges: list[Edge]) -> str:
225
+ """Names for calls dsts in implementation order (dsts are real ids)."""
226
+ resolved: list[tuple[tuple[int, int, str], str]] = []
227
+ for call in call_edges:
228
+ dst_id: str = call.dst_id
229
+ node: Node | None = by_id.get(dst_id)
230
+ if node is not None:
231
+ resolved.append(
232
+ (
233
+ _callee_order_key(call),
234
+ f"{node.name}?" if node.is_placeholder else node.name,
235
+ )
236
+ )
237
+ else:
238
+ resolved.append((_callee_order_key(call), f"{dst_id}?"))
239
+ resolved.sort(key=lambda item: item[0])
240
+ seen: set[str] = set()
241
+ ordered: list[str] = []
242
+ for _, name in resolved:
243
+ if name not in seen:
244
+ seen.add(name)
245
+ ordered.append(name)
246
+ return f"[{', '.join(ordered)}]"
247
+
248
+ print(f"root: {root} (nodes={len(snapshot.nodes)} edges={len(snapshot.edges)})")
249
+ ordered_nodes: list[Node] = sorted(
250
+ snapshot.nodes, key=lambda n: (n.file_path, n.start_line, n.name)
251
+ )
252
+ for node in ordered_nodes:
253
+ parent: str = by_id[node.parent_id].name if node.parent_id in by_id else "None"
254
+ print(
255
+ f"node {kind_label(node.kind):8} {node.name} "
256
+ f"[{node.language}] {_span_of(node)} "
257
+ f"file={node.file_path} parent={parent} "
258
+ f"children={names(children.get(node.id, []))} "
259
+ f"callees={callee_names(calls.get(node.id, []))}"
260
+ )
261
+ return 0
262
+
263
+
264
+ async def print_calls(
265
+ db: ResolvedDb, root: str, graph: PrintableGraph | None = None
266
+ ) -> int:
267
+ """Print every ``calls`` edge of ``root`` as ``caller -> callee``.
268
+
269
+ Dsts are real node ids; a dst missing from the snapshot renders
270
+ with a ``?`` suffix (defensive — the build never emits dangling
271
+ edges since unresolved sites are dropped).
272
+ """
273
+ snapshot = await _snapshot_for(db, root, graph)
274
+ by_id: dict[str, Node] = {n.id: n for n in snapshot.nodes}
275
+ pairs: list[tuple[str, str, str, int]] = []
276
+ for edge in snapshot.edges:
277
+ if kind_label(edge.kind) != "calls":
278
+ continue
279
+ src: Node | None = by_id.get(edge.src_id)
280
+ if src is None:
281
+ continue
282
+ site_line: int = edge.site_line if edge.site_line is not None else 1 << 30
283
+ dst: Node | None = by_id.get(edge.dst_id)
284
+ if dst is not None:
285
+ callee: str = f"{dst.name}?" if dst.is_placeholder else dst.name
286
+ else:
287
+ callee = f"{edge.dst_id}?"
288
+ pairs.append((src.name, callee, src.file_path, site_line))
289
+ if not pairs:
290
+ print(f"no calls for root {root}")
291
+ return 0
292
+ pairs.sort(key=lambda item: (item[2], item[3], item[0], item[1]))
293
+ print(f"root: {root} (calls={len(pairs)})")
294
+ for caller, callee, file_path, _ in pairs:
295
+ print(f"calls {caller} -> {callee} file={file_path}")
296
+ return 0
297
+
298
+
299
+ async def out(
300
+ db: ResolvedDb,
301
+ root: str,
302
+ graph: BuiltGraph,
303
+ *,
304
+ overwrite: bool,
305
+ output: OutputMode = "summary",
306
+ quiet: bool = True,
307
+ persist: bool = True,
308
+ ) -> IndexResult:
309
+ """Persist ``graph`` (flushing first when ``overwrite``), then print.
310
+
311
+ One store for the whole call, so ``:memory:`` databases stay alive
312
+ from persist through the snapshot reads below.
313
+ """
314
+ stored: GraphSnapshot | None = None
315
+ if persist:
316
+ if not db.is_memory:
317
+ # The default DB lives under ~/.codegraph/, which may not
318
+ # exist on first run. Read paths never create directories.
319
+ Path(db.path).parent.mkdir(parents=True, exist_ok=True)
320
+ store = create_store(db.path)
321
+ try:
322
+ await store.create_all()
323
+ if overwrite:
324
+ await store.clear_all()
325
+ if graph.nodes or graph.edges:
326
+ await store.add_all(list(graph.nodes), list(graph.edges))
327
+ if output in ("tree", "nodes", "calls"):
328
+ stored = await load_snapshot(store, root)
329
+ finally:
330
+ await store.dispose()
331
+
332
+ if not quiet:
333
+ verb: str = "indexed" if persist else "scanned"
334
+ print(
335
+ f"{verb} {graph.files} files, {len(graph.nodes)} nodes, "
336
+ f"{len(graph.edges)} edges -> {db.label} [{root}]"
337
+ + (f" ({graph.skipped} skipped)" if graph.skipped else "")
338
+ )
339
+ rendered: PrintableGraph | None = stored if persist else graph
340
+ if output == "tree":
341
+ await print_tree(db, root, rendered)
342
+ elif output == "nodes":
343
+ await print_nodes(db, root, rendered)
344
+ elif output == "calls":
345
+ await print_calls(db, root, rendered)
346
+ return IndexResult(
347
+ root=root,
348
+ db_label=db.label,
349
+ files=graph.files,
350
+ nodes=len(graph.nodes),
351
+ edges=len(graph.edges),
352
+ skipped=graph.skipped,
353
+ )
354
+
355
+
356
+ __all__ = [
357
+ "BuiltGraph",
358
+ "FileInput",
359
+ "IndexResult",
360
+ "OutputMode",
361
+ "PrintableGraph",
362
+ "ScannedSources",
363
+ "build_graph",
364
+ "out",
365
+ "parse_file_input",
366
+ "print_calls",
367
+ "print_nodes",
368
+ "print_tree",
369
+ "read",
370
+ ]
codegraph/query.py ADDED
@@ -0,0 +1,125 @@
1
+ """Agent-facing query shaping: explore-first access to the code graph.
2
+
3
+ Agents never know node id shapes, so every exploration starts from
4
+ `files` (file ids are plain rel paths) or `search` (a name fragment
5
+ -> full rows with ids), then drills via `node` / `callees` /
6
+ `callers` / `children` / `imports` using the discovered ids. All
7
+ functions here are pure given store rows; I/O lives in `cli.py`.
8
+
9
+ JSON envelope (stable keys for chaining):
10
+ `{"root", "verb", "count", "truncated", "items": [...]}` where each
11
+ item is `{"id", "kind", "name", "file_path", "language",
12
+ "start_line", "end_line", "parent_id"}` plus `site_line` on
13
+ `callees` / `callers` items and `target_module` on `imports` items.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from typing import Any
19
+
20
+ from codegraph.models import Node
21
+ from codegraph.tree import kind_label
22
+
23
+ DEFAULT_SEARCH_LIMIT: int = 20
24
+ """Cap for `search` hits; excess sets `truncated` so agents narrow down."""
25
+
26
+
27
+ def to_item(node: Node) -> dict[str, Any]:
28
+ """Project a node onto its stable JSON item shape."""
29
+ return {
30
+ "id": node.id,
31
+ "kind": kind_label(node.kind),
32
+ "name": node.name,
33
+ "file_path": node.file_path,
34
+ "language": node.language,
35
+ "start_line": node.start_line,
36
+ "end_line": node.end_line,
37
+ "parent_id": node.parent_id,
38
+ }
39
+
40
+
41
+ def envelope(root: str, verb: str, items: list[dict[str, Any]]) -> dict[str, Any]:
42
+ """Wrap result items in the stable chaining envelope."""
43
+ return {
44
+ "root": root,
45
+ "verb": verb,
46
+ "count": len(items),
47
+ "truncated": False,
48
+ "items": items,
49
+ }
50
+
51
+
52
+ def search_nodes(
53
+ nodes: list[Node],
54
+ fragment: str,
55
+ *,
56
+ kind: str | None = None,
57
+ file_path: str | None = None,
58
+ limit: int = DEFAULT_SEARCH_LIMIT,
59
+ ) -> tuple[list[Node], bool]:
60
+ """Substring-match def names (case-insensitive), in stable order.
61
+
62
+ Returns ``(hits, truncated)``. File nodes never match — `files`
63
+ lists those. ``kind`` / ``file_path`` narrow exact (kind is
64
+ matched case-insensitively on its plain value).
65
+ """
66
+ needle: str = fragment.strip().lower()
67
+ wanted_kind: str | None = kind.strip().lower() if kind else None
68
+ hits: list[Node] = [
69
+ node
70
+ for node in nodes
71
+ if kind_label(node.kind) != "file"
72
+ and (not needle or needle in node.name.lower())
73
+ and (wanted_kind is None or kind_label(node.kind) == wanted_kind)
74
+ and (file_path is None or node.file_path == file_path)
75
+ ]
76
+ hits.sort(key=lambda n: (n.file_path, n.start_line, n.name))
77
+ if len(hits) > limit:
78
+ return (hits[:limit], True)
79
+ return (hits, False)
80
+
81
+
82
+ def exact_matches(
83
+ nodes: list[Node], name: str, file_path: str | None = None
84
+ ) -> list[Node]:
85
+ """Exact-name matches (file nodes excluded), in stable order.
86
+
87
+ Backs ``--name`` sugar on the drill verbs: zero hits and
88
+ ambiguity are both errors the caller reports with node counts.
89
+ """
90
+ found: list[Node] = [
91
+ node
92
+ for node in nodes
93
+ if kind_label(node.kind) != "file"
94
+ and node.name == name
95
+ and (file_path is None or node.file_path == file_path)
96
+ ]
97
+ found.sort(key=lambda n: (n.file_path, n.start_line))
98
+ return found
99
+
100
+
101
+ def render_human(root: str, verb: str, items: list[dict[str, Any]]) -> str:
102
+ """Render result items as one human-readable line each."""
103
+ lines: list[str] = [f"root: {root} ({verb}={len(items)})"]
104
+ for item in items:
105
+ name: str = str(item["name"])
106
+ kind: str = str(item["kind"])
107
+ file_path: str = str(item["file_path"])
108
+ span: str = f"(L{item['start_line']}-L{item['end_line']})"
109
+ extra: str = ""
110
+ if item.get("site_line") is not None:
111
+ extra += f" site=L{item['site_line']}"
112
+ if item.get("target_module") is not None:
113
+ extra += f" -> {item['target_module']}"
114
+ lines.append(f"{kind} {name} {span} file={file_path}{extra}")
115
+ return "\n".join(lines)
116
+
117
+
118
+ __all__ = [
119
+ "DEFAULT_SEARCH_LIMIT",
120
+ "envelope",
121
+ "exact_matches",
122
+ "render_human",
123
+ "search_nodes",
124
+ "to_item",
125
+ ]