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 ADDED
@@ -0,0 +1,5 @@
1
+ """Sentinel code graph CLI."""
2
+
3
+ from codegraph.models import Edge, EdgeKind, Node, NodeKind
4
+
5
+ __all__ = ["Edge", "EdgeKind", "Node", "NodeKind"]
codegraph/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """`python -m codegraph` entry point."""
2
+
3
+ from codegraph.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ raise SystemExit(main())
codegraph/cli.py ADDED
@@ -0,0 +1,475 @@
1
+ """Sentinel code graph CLI (fully async, I/O at the edge).
2
+
3
+ Flow: ``amain`` parses args, then ``pipeline.read -> pipeline.build_graph
4
+ -> pipeline.out`` for indexing, or the read-only ``run_query`` /
5
+ ``run_stats`` for inspection. Tree-sitter parsing runs on the
6
+ event-loop thread (``Parser`` / ``Tree`` / ``Node`` are not
7
+ thread-safe); only file I/O goes through ``to_thread``.
8
+
9
+ Usage:
10
+ codegraph index <path> [--db PATH] [--overwrite] [--quiet]
11
+ [--output {summary,tree,nodes,calls}] [--persist | --no-persist]
12
+ codegraph stats [--db PATH]
13
+ codegraph query [--db PATH] [--root ROOT] [--json] <verb> ...
14
+
15
+ ``--db`` defaults to ``~/.codegraph/graph.lbdb`` (see
16
+ :mod:`codegraph.config`). The ``query`` verbs are built for agents:
17
+ start from ``files`` / ``search`` to discover node ids, then drill
18
+ with ``node`` / ``callees`` / ``callers`` / ``children`` / ``imports``.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import argparse
24
+ import asyncio
25
+ import json
26
+ import sys
27
+ from pathlib import Path
28
+ from typing import Any
29
+
30
+ from codegraph.config import DEFAULT_DB, ResolvedDb, resolve_db
31
+ from codegraph.graph_store import LadybugStore, create_store
32
+ from codegraph.models import Node
33
+ from codegraph.pipeline import OutputMode, build_graph, out, read
34
+ from codegraph.query import (
35
+ DEFAULT_SEARCH_LIMIT,
36
+ envelope,
37
+ exact_matches,
38
+ render_human,
39
+ search_nodes,
40
+ to_item,
41
+ )
42
+
43
+
44
+ async def run_stats(db: ResolvedDb) -> int:
45
+ """Print database statistics and return the process exit code."""
46
+ store = create_store(db.path)
47
+ try:
48
+ await store.create_all()
49
+ files, nodes, edges = await store.total_counts()
50
+ by_kind = await store.count_by_node_kind()
51
+ by_edge = await store.count_by_edge_kind()
52
+ by_lang = await store.count_by_language()
53
+ top = await store.top_importers()
54
+ finally:
55
+ await store.dispose()
56
+ print(f"db: {db.label}")
57
+ print(f"files: {files} nodes: {nodes} edges: {edges}")
58
+ if by_kind:
59
+ print(
60
+ "nodes by kind: "
61
+ + ", ".join(f"{k}={v}" for k, v in sorted(by_kind.items()))
62
+ )
63
+ if by_edge:
64
+ print(
65
+ "edges by kind: "
66
+ + ", ".join(f"{k}={v}" for k, v in sorted(by_edge.items()))
67
+ )
68
+ if by_lang:
69
+ print(
70
+ "files by language: "
71
+ + ", ".join(f"{k}={v}" for k, v in sorted(by_lang.items()))
72
+ )
73
+ if top:
74
+ print("top importing files:")
75
+ for path, count in top:
76
+ print(f" {count:4d} {path}")
77
+ return 0
78
+
79
+
80
+ _QUERY_VERBS: tuple[str, ...] = (
81
+ "overview",
82
+ "files",
83
+ "search",
84
+ "node",
85
+ "callees",
86
+ "callers",
87
+ "children",
88
+ "imports",
89
+ )
90
+ """Read-only exploration verbs (see :mod:`codegraph.query`)."""
91
+
92
+
93
+ def _add_db_arg(cmd: argparse.ArgumentParser) -> None:
94
+ """Attach the shared ``--db`` flag (known-location default)."""
95
+ cmd.add_argument(
96
+ "--db",
97
+ default=DEFAULT_DB,
98
+ help=f"Ladybug database path or :memory: (default: {DEFAULT_DB})",
99
+ )
100
+
101
+
102
+ def build_parser() -> argparse.ArgumentParser:
103
+ """Build the CLI argument parser."""
104
+ parser = argparse.ArgumentParser(
105
+ prog="codegraph",
106
+ description="Sentinel code graph CLI: tree-sitter nodes/edges into SQL",
107
+ )
108
+ sub = parser.add_subparsers(dest="command", required=True)
109
+
110
+ index_cmd = sub.add_parser("index", help="index a dir/file into the database")
111
+ index_cmd.add_argument("path", help="directory or file to index")
112
+ _add_db_arg(index_cmd)
113
+ index_cmd.add_argument(
114
+ "--overwrite",
115
+ action="store_true",
116
+ help="flush all rows from the database before indexing",
117
+ )
118
+ index_cmd.add_argument(
119
+ "--quiet", action="store_true", help="suppress the summary line"
120
+ )
121
+ index_cmd.add_argument(
122
+ "--output",
123
+ choices=("summary", "tree", "nodes", "calls"),
124
+ default="summary",
125
+ help="index output: summary (default), hierarchy tree, node dump, or call list",
126
+ )
127
+ index_cmd.add_argument(
128
+ "--persist",
129
+ action=argparse.BooleanOptionalAction,
130
+ default=True,
131
+ help="persist rows to --db (use --no-persist for dry-run print only)",
132
+ )
133
+
134
+ stats_cmd = sub.add_parser("stats", help="print database statistics")
135
+ _add_db_arg(stats_cmd)
136
+
137
+ query_cmd = sub.add_parser(
138
+ "query", help="explore the indexed graph (read-only, agent-friendly)"
139
+ )
140
+ _add_db_arg(query_cmd)
141
+ query_cmd.add_argument(
142
+ "--root",
143
+ default=None,
144
+ help="narrow to one indexed root (default: all roots)",
145
+ )
146
+ query_cmd.add_argument(
147
+ "--json",
148
+ action="store_true",
149
+ help="emit the stable chaining envelope as JSON",
150
+ )
151
+ qsub = query_cmd.add_subparsers(dest="query_verb", required=True)
152
+ qsub.add_parser("overview", help="index counts by kind and language")
153
+ qsub.add_parser("files", help="indexed files (file ids are plain rel paths)")
154
+ search_cmd = qsub.add_parser(
155
+ "search", help="substring-match def names -> rows with ids"
156
+ )
157
+ search_cmd.add_argument("--name", required=True, help="name fragment to match")
158
+ search_cmd.add_argument(
159
+ "--kind",
160
+ default=None,
161
+ help="narrow to one node kind (class|function|method|interface|type)",
162
+ )
163
+ search_cmd.add_argument("--file", default=None, help="narrow to one rel path")
164
+ search_cmd.add_argument(
165
+ "--limit",
166
+ type=int,
167
+ default=DEFAULT_SEARCH_LIMIT,
168
+ help=f"max hits (default: {DEFAULT_SEARCH_LIMIT})",
169
+ )
170
+ for verb in ("node", "callees", "callers", "children"):
171
+ verb_cmd = qsub.add_parser(verb, help=f"{verb} for one node")
172
+ verb_cmd.add_argument("--id", default=None, help="exact node id")
173
+ verb_cmd.add_argument("--name", default=None, help="exact def name")
174
+ verb_cmd.add_argument("--file", default=None, help="narrow --name to one file")
175
+ imports_cmd = qsub.add_parser("imports", help="one file's imports")
176
+ imports_cmd.add_argument("--file", default=None, help="file rel path")
177
+ imports_cmd.add_argument("--id", default=None, help="exact file node id")
178
+ return parser
179
+
180
+
181
+ def _missing_db_message(db: ResolvedDb) -> str:
182
+ """Friendly error when a read targets a database that was never indexed."""
183
+ return f"error: no database at {db.label}; run `codegraph index` first"
184
+
185
+
186
+ async def run_query(
187
+ db: ResolvedDb,
188
+ *,
189
+ root_filter: str | None,
190
+ verb: str,
191
+ node_id: str | None,
192
+ name: str | None,
193
+ file: str | None,
194
+ kind: str | None,
195
+ limit: int,
196
+ as_json: bool,
197
+ ) -> int:
198
+ """Run one read-only exploration verb against ``db``.
199
+
200
+ Returns 0 on success (even with zero hits — ``count`` says so),
201
+ 1 on resolution errors (unknown id, no/ambiguous name, bad limit).
202
+ Never writes rows.
203
+ """
204
+ root_label: str = root_filter if root_filter is not None else "all"
205
+ store = create_store(db.path)
206
+ try:
207
+ await store.create_all()
208
+ if verb == "overview":
209
+ return await _run_overview(store, db, root_filter, root_label, as_json)
210
+ if verb == "files":
211
+ items: list[dict[str, Any]] = [
212
+ to_item(n) for n in await store.list_files(root_filter)
213
+ ]
214
+ return _emit(db, root_label, verb, items, False, as_json)
215
+ if verb == "search":
216
+ if limit <= 0:
217
+ print("error: --limit must be a positive integer", file=sys.stderr)
218
+ return 1
219
+ nodes: list[Node] = await store.list_nodes(root_filter)
220
+ hits, truncated = search_nodes(
221
+ nodes, name or "", kind=kind, file_path=file, limit=limit
222
+ )
223
+ return _emit(
224
+ db, root_label, verb, [to_item(n) for n in hits], truncated, as_json
225
+ )
226
+ if verb == "imports":
227
+ return await _run_imports(
228
+ store, db, root_filter, root_label, node_id, file, as_json
229
+ )
230
+ return await _run_drill(
231
+ store, db, root_filter, root_label, verb, node_id, name, file, as_json
232
+ )
233
+ finally:
234
+ await store.dispose()
235
+
236
+
237
+ async def _run_overview(
238
+ store: LadybugStore, db: ResolvedDb, root_filter: str | None, root_label: str, as_json: bool
239
+ ) -> int:
240
+ """Emit index counts (by kind, edge, language)."""
241
+ files, nodes, edges = await store.total_counts(root_filter)
242
+ by_kind = await store.count_by_node_kind(root_filter)
243
+ by_edge = await store.count_by_edge_kind(root_filter)
244
+ by_lang = await store.count_by_language(root_filter)
245
+ if as_json:
246
+ print(
247
+ json.dumps(
248
+ {
249
+ "root": root_label,
250
+ "verb": "overview",
251
+ "files": files,
252
+ "nodes": nodes,
253
+ "edges": edges,
254
+ "by_kind": by_kind,
255
+ "by_edge": by_edge,
256
+ "by_language": by_lang,
257
+ },
258
+ indent=2,
259
+ )
260
+ )
261
+ return 0
262
+ print(f"db: {db.label}")
263
+ print(f"files: {files} nodes: {nodes} edges: {edges}")
264
+ if by_kind:
265
+ print(
266
+ "nodes by kind: "
267
+ + ", ".join(f"{k}={v}" for k, v in sorted(by_kind.items()))
268
+ )
269
+ if by_edge:
270
+ print(
271
+ "edges by kind: "
272
+ + ", ".join(f"{k}={v}" for k, v in sorted(by_edge.items()))
273
+ )
274
+ if by_lang:
275
+ print(
276
+ "files by language: "
277
+ + ", ".join(f"{k}={v}" for k, v in sorted(by_lang.items()))
278
+ )
279
+ return 0
280
+
281
+
282
+ def _emit(
283
+ db: ResolvedDb,
284
+ root_label: str,
285
+ verb: str,
286
+ items: list[dict[str, Any]],
287
+ truncated: bool,
288
+ as_json: bool,
289
+ ) -> int:
290
+ """Print one row-verb result as JSON envelope or human lines."""
291
+ if as_json:
292
+ payload: dict[str, Any] = envelope(root_label, verb, items)
293
+ payload["truncated"] = truncated
294
+ print(json.dumps(payload, indent=2))
295
+ else:
296
+ print(render_human(root_label, verb, items))
297
+ if truncated:
298
+ print("(truncated: narrow with --kind/--file/--limit)")
299
+ return 0
300
+
301
+
302
+ async def _resolve_drill_target(
303
+ store: LadybugStore,
304
+ root_filter: str | None,
305
+ node_id: str | None,
306
+ name: str | None,
307
+ file: str | None,
308
+ ) -> Node | int:
309
+ """Resolve ``--id`` / ``--name`` sugar to a node, or an exit code.
310
+
311
+ Returns the :class:`Node` on success, else 1 after printing the
312
+ error (unknown id, missing name, or ambiguity with hit counts).
313
+ """
314
+ if node_id is not None:
315
+ node: Node | None = await store.get_node(node_id, root_filter)
316
+ if node is None:
317
+ print(f"error: no node with id {node_id!r}", file=sys.stderr)
318
+ return 1
319
+ return node
320
+ if name is None:
321
+ print("error: one of --id or --name is required", file=sys.stderr)
322
+ return 1
323
+ matches: list[Node] = exact_matches(
324
+ await store.list_nodes(root_filter), name, file
325
+ )
326
+ if not matches:
327
+ scope: str = f" in file {file!r}" if file else ""
328
+ print(f"error: no node named {name!r}{scope}", file=sys.stderr)
329
+ return 1
330
+ if len(matches) > 1:
331
+ print(f"error: ambiguous name {name!r} ({len(matches)} hits):", file=sys.stderr)
332
+ for hit in matches:
333
+ print(
334
+ f" {hit.id} [{hit.kind.value}] file={hit.file_path}",
335
+ file=sys.stderr,
336
+ )
337
+ print("narrow with --file or use --id", file=sys.stderr)
338
+ return 1
339
+ return matches[0]
340
+
341
+
342
+ async def _run_drill(
343
+ store: LadybugStore,
344
+ db: ResolvedDb,
345
+ root_filter: str | None,
346
+ root_label: str,
347
+ verb: str,
348
+ node_id: str | None,
349
+ name: str | None,
350
+ file: str | None,
351
+ as_json: bool,
352
+ ) -> int:
353
+ """Run ``node`` / ``callees`` / ``callers`` / ``children``."""
354
+ target: Node | int = await _resolve_drill_target(
355
+ store, root_filter, node_id, name, file
356
+ )
357
+ if isinstance(target, int):
358
+ return target
359
+ items: list[dict[str, Any]] = []
360
+ if verb == "node":
361
+ items = [to_item(target)]
362
+ elif verb == "callees":
363
+ items = [
364
+ {**to_item(node), "site_line": site}
365
+ for node, site in await store.callees(target.id, root_filter)
366
+ ]
367
+ elif verb == "callers":
368
+ items = [
369
+ {**to_item(node), "site_line": site}
370
+ for node, site in await store.callers(target.id, root_filter)
371
+ ]
372
+ else: # children
373
+ items = [to_item(node) for node in await store.children(target.id, root_filter)]
374
+ return _emit(db, root_label, verb, items, False, as_json)
375
+
376
+
377
+ async def _run_imports(
378
+ store: LadybugStore,
379
+ db: ResolvedDb,
380
+ root_filter: str | None,
381
+ root_label: str,
382
+ node_id: str | None,
383
+ file: str | None,
384
+ as_json: bool,
385
+ ) -> int:
386
+ """Run ``imports`` for one file (by rel path or file node id)."""
387
+ file_rel: str | None = file
388
+ if node_id is not None:
389
+ node: Node | None = await store.get_node(node_id, root_filter)
390
+ if node is None:
391
+ print(f"error: no node with id {node_id!r}", file=sys.stderr)
392
+ return 1
393
+ if str(node.kind.value) != "file":
394
+ print(
395
+ f"error: node {node_id!r} is a {node.kind.value}, not a file "
396
+ "(pass a file rel path via --file)",
397
+ file=sys.stderr,
398
+ )
399
+ return 1
400
+ file_rel = node.file_path
401
+ if file_rel is None:
402
+ print("error: one of --file or --id is required", file=sys.stderr)
403
+ return 1
404
+ items: list[dict[str, Any]] = [
405
+ {**to_item(node), "target_module": module}
406
+ for node, module in await store.file_imports(file_rel, root_filter)
407
+ ]
408
+ return _emit(db, root_label, "imports", items, False, as_json)
409
+
410
+
411
+ async def amain(argv: list[str] | None = None) -> int:
412
+ """Async entry point (kept separate for testability)."""
413
+ args = build_parser().parse_args(argv)
414
+ try:
415
+ db: ResolvedDb = resolve_db(str(args.db))
416
+ except ValueError as exc:
417
+ print(f"error: {exc}", file=sys.stderr)
418
+ return 2
419
+ if args.command == "stats":
420
+ if not db.is_memory and not Path(db.path).exists():
421
+ print(_missing_db_message(db), file=sys.stderr)
422
+ return 1
423
+ return await run_stats(db)
424
+ if args.command == "query":
425
+ if not db.is_memory and not Path(db.path).exists():
426
+ print(_missing_db_message(db), file=sys.stderr)
427
+ return 1
428
+ return await run_query(
429
+ db,
430
+ root_filter=args.root,
431
+ verb=str(args.query_verb),
432
+ node_id=getattr(args, "id", None),
433
+ name=getattr(args, "name", None),
434
+ file=getattr(args, "file", None),
435
+ kind=getattr(args, "kind", None),
436
+ limit=int(getattr(args, "limit", DEFAULT_SEARCH_LIMIT)),
437
+ as_json=bool(args.json),
438
+ )
439
+ if args.command == "index":
440
+ target = Path(str(args.path)).expanduser()
441
+ if not target.exists():
442
+ print(f"error: path does not exist: {target}", file=sys.stderr)
443
+ return 2
444
+ raw_output: str = str(args.output)
445
+ output: OutputMode = (
446
+ "tree"
447
+ if raw_output == "tree"
448
+ else "nodes"
449
+ if raw_output == "nodes"
450
+ else "calls"
451
+ if raw_output == "calls"
452
+ else "summary"
453
+ )
454
+ scanned = await read(target.resolve())
455
+ graph = build_graph(scanned.root, scanned.items)
456
+ await out(
457
+ db,
458
+ scanned.root,
459
+ graph,
460
+ overwrite=bool(args.overwrite),
461
+ output=output,
462
+ quiet=bool(args.quiet),
463
+ persist=bool(args.persist),
464
+ )
465
+ return 0
466
+ return 2
467
+
468
+
469
+ def main(argv: list[str] | None = None) -> int:
470
+ """Sync wrapper: run the async CLI."""
471
+ return asyncio.run(amain(argv))
472
+
473
+
474
+ if __name__ == "__main__":
475
+ raise SystemExit(main())
codegraph/config.py ADDED
@@ -0,0 +1,55 @@
1
+ """Database path resolution for the CLI.
2
+
3
+ The graph lives in an embedded Ladybug database — there is no server,
4
+ so ``--db`` accepts only a filesystem path (on-disk ``.lbdb``) or
5
+ ``:memory:`` (ephemeral). The default is a known home-directory
6
+ location so query time never has to guess where the index lives:
7
+
8
+ - ``~/.codegraph/graph.lbdb`` → default on-disk database.
9
+ - ``:memory:`` → temporary database, lost when the process exits.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from dataclasses import dataclass
15
+ from pathlib import Path
16
+
17
+
18
+ DEFAULT_DB: str = "~/.codegraph/graph.lbdb"
19
+ """Default ``--db`` value: the known-location on-disk database."""
20
+
21
+
22
+ @dataclass(frozen=True, slots=True)
23
+ class ResolvedDb:
24
+ """A Ladybug database path plus a human label."""
25
+
26
+ path: str
27
+ label: str
28
+ is_memory: bool
29
+
30
+
31
+ def resolve_db(raw: str) -> ResolvedDb:
32
+ """Normalise a ``--db`` value into a Ladybug database path.
33
+
34
+ Raises:
35
+ ValueError: when the value is empty or uses a URL scheme
36
+ (Ladybug is embedded — pass a file path or ``:memory:``).
37
+ """
38
+ value: str = raw.strip()
39
+ if not value:
40
+ raise ValueError("--db must not be empty")
41
+
42
+ if value == ":memory:":
43
+ return ResolvedDb(path=":memory:", label=":memory:", is_memory=True)
44
+
45
+ if "://" in value:
46
+ raise ValueError(
47
+ f"unsupported --db scheme: {value!r} "
48
+ "(expected a file path or :memory: — Ladybug is embedded)"
49
+ )
50
+
51
+ path: Path = Path(value).expanduser().resolve()
52
+ return ResolvedDb(path=path.as_posix(), label=path.as_posix(), is_memory=False)
53
+
54
+
55
+ __all__ = ["DEFAULT_DB", "ResolvedDb", "resolve_db"]