heimdall-code 0.1.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.
heimdall/SKILL.md ADDED
@@ -0,0 +1,54 @@
1
+ ---
2
+ name: heimdall
3
+ description: Answer "what calls this?", "what breaks if I change it?" and "how does A reach B?" from a precomputed code graph instead of grepping. Use whenever a question is about how a codebase is wired -- callers, blast radius, dead code, call paths -- in any of 18 languages.
4
+ ---
5
+
6
+ # Heimdall
7
+
8
+ A SQLite call/import/inheritance graph of the repository. One index, then every
9
+ structural question is a single fast query instead of a grep sweep.
10
+
11
+ ## First
12
+
13
+ ```bash
14
+ heimdall index # build .heimdall/graph.db for the current repo (~1.5s for Django)
15
+ ```
16
+
17
+ Idempotent — re-run after any large change. `heimdall projects` lists what is already
18
+ indexed; every command takes `-p NAME` to query another repo, and `--json` for parsing.
19
+
20
+ ## Then
21
+
22
+ | Question | Command |
23
+ |---|---|
24
+ | what is this repo? | `heimdall stats` |
25
+ | where is the thing called roughly *this*? | `heimdall search "retry backoff"` |
26
+ | who calls X, what does X call, what is X? | `heimdall context X` |
27
+ | what breaks if I change X? | `heimdall impact X -d 2 -c 0.7` |
28
+ | how does A reach B? | `heimdall trace A B` |
29
+ | anything else | `heimdall sql "SELECT ..."` |
30
+
31
+ `X` is a bare name (`save`), a qualified name (`app/models.py:User.save`) or a file path.
32
+ A receiver disambiguates: `context Model.check` answers for the `Model` that defines
33
+ `check`, not for every `check` in the tree.
34
+
35
+ ## Confidence
36
+
37
+ Call edges are scored 0.3 (name match only) to 1.0 (certain). `impact` and `trace`
38
+ default to 0.3 and 0.5. Raise with `-c 0.7` when a wide answer looks like noise —
39
+ `client.get()` on an inferred variable is the usual source of the extra rows.
40
+
41
+ ## SQL escape hatch
42
+
43
+ ```sql
44
+ node(id, kind, name, qname, path, line, end_line, lang, signature) -- Folder|File|Class|Interface|Function|Method
45
+ edge(src, dst, kind, confidence, line) -- CONTAINS|DEFINES|IMPORTS|CALLS|EXTENDS
46
+ ```
47
+
48
+ Read-only, one `SELECT` or `WITH` per call; recursive CTEs work. That is the whole model.
49
+
50
+ ## Don't
51
+
52
+ - Don't grep for callers when the repo is indexed — `context` already has them, with lines.
53
+ - Don't trust a 0.3 edge as a fact; say it is a candidate.
54
+ - Don't re-index inside a loop. Once per session is enough.
heimdall/__init__.py ADDED
@@ -0,0 +1,13 @@
1
+ # Copyright 2026 The Heimdall Authors
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Heimdall -- a code knowledge graph that sees everything.
4
+
5
+ Indexes a repository into a SQLite property graph (symbols, calls, imports,
6
+ inheritance) and serves it to agents over MCP.
7
+ """
8
+
9
+ from .graph import Graph
10
+ from .index import build
11
+
12
+ __version__ = "0.1.0"
13
+ __all__ = ["Graph", "__version__", "build"]
heimdall/__main__.py ADDED
@@ -0,0 +1,8 @@
1
+ # Copyright 2026 The Heimdall Authors
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ import sys
4
+
5
+ from .cli import main
6
+
7
+ if __name__ == "__main__":
8
+ sys.exit(main())
heimdall/cli.py ADDED
@@ -0,0 +1,200 @@
1
+ # Copyright 2026 The Heimdall Authors
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Command line interface."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import argparse
8
+ import json
9
+ import sys
10
+ from pathlib import Path
11
+
12
+ from . import registry
13
+ from .graph import Graph
14
+ from .index import build
15
+
16
+
17
+ def _log(msg: str) -> None:
18
+ print(msg, file=sys.stderr, flush=True)
19
+
20
+
21
+ def _emit(data, as_json: bool) -> None:
22
+ if as_json:
23
+ json.dump(data, sys.stdout, indent=2, default=str)
24
+ print()
25
+ else:
26
+ _render(data)
27
+
28
+
29
+ def _loc(n: dict) -> str:
30
+ return f"{n.get('path')}:{n.get('line')}" if n.get("line") else str(n.get("path") or "")
31
+
32
+
33
+ def _line(n: dict, prefix: str = "") -> str:
34
+ conf = n.get("confidence")
35
+ tag = f" ({conf:.2f})" if isinstance(conf, float) and conf < 1.0 else ""
36
+ return f"{prefix}{n.get('kind', ''):<10} {n.get('qname', '')}{tag}\n{prefix}{'':<10} {_loc(n)}"
37
+
38
+
39
+ def _render(data) -> None:
40
+ if isinstance(data, list):
41
+ if data and not all(isinstance(n, dict) and "qname" in n and "kind" in n for n in data):
42
+ for row in data: # arbitrary SQL result
43
+ print(json.dumps(row, default=str))
44
+ return
45
+ for n in data:
46
+ print(_line(n))
47
+ return
48
+ if not isinstance(data, dict):
49
+ print(data)
50
+ return
51
+ if "error" in data:
52
+ print(f"error: {data['error']}")
53
+ for s in data.get("suggestions", []):
54
+ print(f" did you mean {s['qname']}")
55
+ return
56
+ if "symbol" in data: # context
57
+ s = data["symbol"]
58
+ print(f"{s['kind']} {s['qname']}\n {_loc(s)}\n {s['signature']}")
59
+ for key in ("defined_in", "members", "calls", "callers", "extends", "extended_by", "ambiguous_with"):
60
+ v = data.get(key)
61
+ if not v:
62
+ continue
63
+ rows = v if isinstance(v, list) else [v]
64
+ print(f"\n{key.replace('_', ' ')} ({len(rows)}):")
65
+ for n in rows[:25]:
66
+ print(_line(n, " "))
67
+ if len(rows) > 25:
68
+ print(f" ... {len(rows) - 25} more")
69
+ return
70
+ if "by_depth" in data: # impact
71
+ print(f"impact of {data['target']['qname']}: {data['total']} symbols, {len(data['files_affected'])} files")
72
+ for depth in sorted(data["by_depth"], key=int):
73
+ rows = data["by_depth"][depth]
74
+ print(f"\ndepth {depth} ({len(rows)}):")
75
+ for n in rows[:25]:
76
+ print(_line(n, " "))
77
+ if len(rows) > 25:
78
+ print(f" ... {len(rows) - 25} more")
79
+ return
80
+ if "found" in data: # trace
81
+ if not data["found"]:
82
+ print("no path found")
83
+ return
84
+ print(f"path of length {data['length']}:")
85
+ for i, n in enumerate(data["path"]):
86
+ print(f" {' ' * i}-> {n['qname']} [{_loc(n)}]")
87
+ return
88
+ print(json.dumps(data, indent=2, default=str))
89
+
90
+
91
+ def main(argv: list[str] | None = None) -> int:
92
+ # the same two flags before or after the subcommand; SUPPRESS keeps the subparser
93
+ # from overwriting a value already given globally
94
+ common = argparse.ArgumentParser(add_help=False)
95
+ common.add_argument("--json", action="store_true", default=argparse.SUPPRESS, help="emit raw JSON")
96
+ common.add_argument("-p", "--project", default=argparse.SUPPRESS, help="indexed project name or path to a graph.db")
97
+
98
+ ap = argparse.ArgumentParser(prog="heimdall", description="Code knowledge graph for AI agents.")
99
+ ap.add_argument("--json", action="store_true", help="emit raw JSON")
100
+ ap.add_argument("-p", "--project", help="indexed project name or path to a graph.db")
101
+ sub = ap.add_subparsers(dest="cmd", required=True)
102
+
103
+ def cmd(name, help):
104
+ return sub.add_parser(name, help=help, parents=[common])
105
+
106
+ p = cmd("index", "build or rebuild the graph for a repository")
107
+ p.add_argument("root", nargs="?", default=".", type=Path)
108
+ p.add_argument("--db", type=Path, help="output database (default <root>/.heimdall/graph.db)")
109
+ p.add_argument("-j", "--jobs", type=int, default=0, help="parser processes (default: all cores)")
110
+ p.add_argument("--name", help="registry name (default: directory name)")
111
+
112
+ p = cmd("search", "BM25 symbol search")
113
+ p.add_argument("query")
114
+ p.add_argument("-k", "--kind", help="Function|Method|Class|Interface|File")
115
+ p.add_argument("-n", "--limit", type=int, default=20)
116
+
117
+ p = cmd("context", "full 360 view of one symbol")
118
+ p.add_argument("symbol")
119
+
120
+ p = cmd("impact", "what breaks if this symbol changes")
121
+ p.add_argument("symbol")
122
+ p.add_argument("-d", "--depth", type=int, default=3)
123
+ p.add_argument("-c", "--min-confidence", type=float, default=0.3)
124
+ p.add_argument("-n", "--per-depth", type=int, default=40)
125
+
126
+ p = cmd("trace", "shortest path between two symbols")
127
+ p.add_argument("source")
128
+ p.add_argument("target")
129
+ p.add_argument("-d", "--depth", type=int, default=8)
130
+ p.add_argument("-c", "--min-confidence", type=float, default=0.5)
131
+
132
+ cmd("stats", "what is in the graph")
133
+ cmd("projects", "list indexed repositories")
134
+ cmd("mcp", "run the MCP server on stdio")
135
+
136
+ # no `parents=[common]`: this one owns --project, and it takes no -p/--project target
137
+ p = sub.add_parser("install", help="register heimdall with a coding agent")
138
+ p.add_argument("agent", nargs="?", default="all", choices=["claude", "codex", "all"])
139
+ p.add_argument("--project", action="store_true", help="write into this repository instead of your home config")
140
+ p.add_argument(
141
+ "--cli", action="store_true", help="install the skill that drives the CLI, instead of the MCP server"
142
+ )
143
+
144
+ p = cmd("sql", "read-only SQL against the graph")
145
+ p.add_argument("query")
146
+ p.add_argument("-n", "--limit", type=int, default=100)
147
+
148
+ a = ap.parse_args(argv)
149
+
150
+ if a.cmd == "index":
151
+ root = a.root.resolve()
152
+ db = a.db or registry.db_path_for(root)
153
+ stats = build(root, db, jobs=a.jobs, log=_log)
154
+ registry.register(a.name or root.name, db)
155
+ _emit(stats, a.json)
156
+ return 0
157
+
158
+ if a.cmd == "projects":
159
+ entries = registry.load()
160
+ if a.json:
161
+ _emit(entries, True)
162
+ else:
163
+ for name, db in sorted(entries.items()):
164
+ print(f"{name:<28} {db}")
165
+ return 0
166
+
167
+ if a.cmd == "install":
168
+ from .install import install
169
+
170
+ lines = install(a.agent, project=a.project, cli=a.cli)
171
+ if a.json:
172
+ _emit(lines, True)
173
+ else:
174
+ print(*lines, sep="\n")
175
+ return 1 if any(line.startswith("!") for line in lines) else 0
176
+
177
+ if a.cmd == "mcp":
178
+ from .mcp_server import serve
179
+
180
+ serve(a.project)
181
+ return 0
182
+
183
+ g = Graph.open(registry.resolve(a.project), readonly=True)
184
+ try:
185
+ match a.cmd:
186
+ case "search":
187
+ _emit(g.search(a.query, a.limit, a.kind), a.json)
188
+ case "context":
189
+ _emit(g.context(a.symbol), a.json)
190
+ case "impact":
191
+ _emit(g.impact(a.symbol, a.depth, a.min_confidence, a.per_depth), a.json)
192
+ case "trace":
193
+ _emit(g.trace(a.source, a.target, a.depth, a.min_confidence), a.json)
194
+ case "stats":
195
+ _emit(g.stats(), True)
196
+ case "sql":
197
+ _emit(g.query(a.query, a.limit), a.json)
198
+ finally:
199
+ g.close()
200
+ return 0
heimdall/graph.py ADDED
@@ -0,0 +1,286 @@
1
+ # Copyright 2026 The Heimdall Authors
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """The knowledge graph, stored in SQLite.
4
+
5
+ A graph database is two tables and a recursive CTE. SQLite ships with Python, does
6
+ BM25 through FTS5, and handles a few million edges without complaint -- so there is
7
+ no embedded graph engine, no WASM, and no server here.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import re
14
+ import sqlite3
15
+ from dataclasses import dataclass
16
+ from pathlib import Path
17
+
18
+ SCHEMA = """
19
+ PRAGMA journal_mode=WAL;
20
+ CREATE TABLE IF NOT EXISTS node(
21
+ id INTEGER PRIMARY KEY,
22
+ kind TEXT NOT NULL, -- Folder|File|Function|Method|Class|Interface
23
+ name TEXT NOT NULL,
24
+ qname TEXT NOT NULL UNIQUE,
25
+ path TEXT,
26
+ line INTEGER DEFAULT 0,
27
+ end_line INTEGER DEFAULT 0,
28
+ lang TEXT,
29
+ signature TEXT DEFAULT ''
30
+ );
31
+ CREATE TABLE IF NOT EXISTS edge(
32
+ src INTEGER NOT NULL REFERENCES node(id),
33
+ dst INTEGER NOT NULL REFERENCES node(id),
34
+ kind TEXT NOT NULL, -- CONTAINS|DEFINES|CALLS|IMPORTS|EXTENDS
35
+ confidence REAL DEFAULT 1.0,
36
+ line INTEGER DEFAULT 0,
37
+ PRIMARY KEY (src, dst, kind, line)
38
+ ) WITHOUT ROWID;
39
+ CREATE TABLE IF NOT EXISTS meta(key TEXT PRIMARY KEY, value TEXT);
40
+ CREATE INDEX IF NOT EXISTS node_name ON node(name);
41
+ CREATE INDEX IF NOT EXISTS node_path ON node(path);
42
+ CREATE INDEX IF NOT EXISTS node_kind ON node(kind);
43
+ CREATE INDEX IF NOT EXISTS edge_dst ON edge(dst, kind);
44
+ CREATE INDEX IF NOT EXISTS edge_src ON edge(src, kind);
45
+ CREATE VIRTUAL TABLE IF NOT EXISTS node_fts
46
+ USING fts5(name, qname, signature, content='node', content_rowid='id', tokenize='porter unicode61');
47
+ """
48
+
49
+ TRAVERSABLE = ("CALLS", "EXTENDS", "IMPORTS")
50
+
51
+
52
+ @dataclass(slots=True)
53
+ class Graph:
54
+ db: sqlite3.Connection
55
+
56
+ # ------------------------------------------------------------ lifecycle
57
+
58
+ @staticmethod
59
+ def open(path: str | Path, *, readonly: bool = False) -> Graph:
60
+ path = Path(path)
61
+ if readonly:
62
+ db = sqlite3.connect(f"file:{path}?mode=ro", uri=True)
63
+ else:
64
+ path.parent.mkdir(parents=True, exist_ok=True)
65
+ db = sqlite3.connect(path)
66
+ db.executescript(SCHEMA)
67
+ db.row_factory = sqlite3.Row
68
+ return Graph(db)
69
+
70
+ def close(self) -> None:
71
+ self.db.close()
72
+
73
+ def reindex_fts(self) -> None:
74
+ self.db.execute("INSERT INTO node_fts(node_fts) VALUES('rebuild')")
75
+
76
+ def set_meta(self, **kv: object) -> None:
77
+ self.db.executemany(
78
+ "INSERT OR REPLACE INTO meta(key, value) VALUES (?, ?)",
79
+ [(k, json.dumps(v)) for k, v in kv.items()],
80
+ )
81
+
82
+ def meta(self) -> dict:
83
+ return {r["key"]: json.loads(r["value"]) for r in self.db.execute("SELECT key, value FROM meta")}
84
+
85
+ # ------------------------------------------------------------ writing
86
+
87
+ def add_nodes(self, rows) -> None:
88
+ self.db.executemany(
89
+ "INSERT OR IGNORE INTO node(kind,name,qname,path,line,end_line,lang,signature)"
90
+ " VALUES (:kind,:name,:qname,:path,:line,:end_line,:lang,:signature)",
91
+ rows,
92
+ )
93
+
94
+ def add_edges(self, rows) -> None:
95
+ self.db.executemany("INSERT OR IGNORE INTO edge(src,dst,kind,confidence,line) VALUES (?,?,?,?,?)", rows)
96
+
97
+ def ids_by_qname(self) -> dict[str, int]:
98
+ return {r["qname"]: r["id"] for r in self.db.execute("SELECT id, qname FROM node")}
99
+
100
+ # ------------------------------------------------------------ reading
101
+
102
+ def resolve(self, ref: str) -> list[sqlite3.Row]:
103
+ """Find nodes by exact qname, then by path, then by bare name."""
104
+ for sql, arg in (
105
+ ("SELECT * FROM node WHERE qname = ?", ref),
106
+ ("SELECT * FROM node WHERE path = ?", ref),
107
+ ("SELECT * FROM node WHERE name = ? ORDER BY kind, qname LIMIT 25", ref),
108
+ ("SELECT * FROM node WHERE qname LIKE ? ORDER BY length(qname) LIMIT 25", f"%{ref}"),
109
+ ):
110
+ rows = self.db.execute(sql, (arg,)).fetchall()
111
+ if rows:
112
+ return rows
113
+ return []
114
+
115
+ @staticmethod
116
+ def _fts_query(query: str) -> str:
117
+ """Free text -> FTS5. Terms are OR'd with prefix matching, so `retry backoff`
118
+ ranks symbols mentioning either and symbols mentioning both highest."""
119
+ words = re.findall(r"\w+", query)
120
+ if not words:
121
+ return ""
122
+ return " OR ".join(f'"{w}"*' for w in words)
123
+
124
+ def search(self, query: str, limit: int = 20, kind: str | None = None) -> list[dict]:
125
+ """BM25 ranked symbol search. Falls back to substring match for odd queries."""
126
+ # folders match everything under them and drown out real symbols; ask for them by name
127
+ where = " AND n.kind = ?" if kind else " AND n.kind != 'Folder'"
128
+ args: list = [self._fts_query(query)]
129
+ if kind:
130
+ args.append(kind)
131
+ args.append(limit)
132
+ try:
133
+ rows = self.db.execute(
134
+ "SELECT n.*, bm25(node_fts, 6.0, 3.0, 1.0) AS score FROM node_fts"
135
+ " JOIN node n ON n.id = node_fts.rowid"
136
+ f" WHERE node_fts MATCH ?{where} ORDER BY score LIMIT ?",
137
+ args,
138
+ ).fetchall()
139
+ except sqlite3.OperationalError: # unquotable FTS syntax, e.g. a bare "*"
140
+ rows = []
141
+ if not rows:
142
+ rows = self.db.execute(
143
+ "SELECT *, 0 AS score FROM node WHERE name LIKE ?"
144
+ + (" AND kind = ?" if kind else " AND kind != 'Folder'")
145
+ + " ORDER BY length(name) LIMIT ?",
146
+ [f"%{query}%"] + ([kind] if kind else []) + [limit],
147
+ ).fetchall()
148
+ return [dict(r) for r in rows]
149
+
150
+ @staticmethod
151
+ def _slim(row) -> dict:
152
+ """List entries carry only what an agent needs to act: what, where, how sure.
153
+ Full records are reserved for the symbol actually asked about."""
154
+ out = {k: row[k] for k in ("kind", "qname", "path", "line") if k in row.keys()}
155
+ conf = row["confidence"] if "confidence" in row.keys() else None
156
+ if conf is not None and conf < 1.0:
157
+ out["confidence"] = round(conf, 2)
158
+ if "depth" in row.keys():
159
+ out["depth"] = row["depth"]
160
+ return out
161
+
162
+ def edges_of(self, node_id: int, kind: str, direction: str) -> list[dict]:
163
+ col, other = ("src", "dst") if direction == "out" else ("dst", "src")
164
+ return [
165
+ self._slim(r)
166
+ for r in self.db.execute(
167
+ f"SELECT n.*, e.confidence, e.line AS at_line FROM edge e"
168
+ f" JOIN node n ON n.id = e.{other}"
169
+ f" WHERE e.{col} = ? AND e.kind = ? ORDER BY e.confidence DESC LIMIT 200",
170
+ (node_id, kind),
171
+ )
172
+ ]
173
+
174
+ def context(self, ref: str) -> dict:
175
+ """Everything about one symbol: where it lives, who calls it, what it calls."""
176
+ matches = self.resolve(ref)
177
+ if not matches:
178
+ return {"error": f"no symbol matching {ref!r}", "suggestions": self.search(ref, 5)}
179
+ n = matches[0]
180
+ nid = n["id"]
181
+ parent = self.db.execute(
182
+ "SELECT n.* FROM edge e JOIN node n ON n.id = e.src WHERE e.dst = ? AND e.kind = 'DEFINES'", (nid,)
183
+ ).fetchone()
184
+ return {
185
+ "symbol": dict(n),
186
+ "defined_in": self._slim(parent) if parent else None,
187
+ "members": self.edges_of(nid, "DEFINES", "out"),
188
+ "callers": self.edges_of(nid, "CALLS", "in"),
189
+ "calls": self.edges_of(nid, "CALLS", "out"),
190
+ "extends": self.edges_of(nid, "EXTENDS", "out"),
191
+ "extended_by": self.edges_of(nid, "EXTENDS", "in"),
192
+ "ambiguous_with": [self._slim(m) for m in matches[1:6]],
193
+ }
194
+
195
+ def impact(self, ref: str, depth: int = 3, min_confidence: float = 0.3, per_depth: int = 40) -> dict:
196
+ """Blast radius: everything that transitively reaches this symbol, by depth."""
197
+ matches = self.resolve(ref)
198
+ if not matches:
199
+ return {"error": f"no symbol matching {ref!r}", "suggestions": self.search(ref, 5)}
200
+ nid = matches[0]["id"]
201
+ rows = self.db.execute(
202
+ """
203
+ WITH RECURSIVE up(id, d, conf) AS (
204
+ SELECT ?, 0, 1.0
205
+ UNION
206
+ SELECT e.src, up.d + 1, min(up.conf, e.confidence)
207
+ FROM edge e JOIN up ON e.dst = up.id
208
+ WHERE e.kind IN ('CALLS','EXTENDS') AND up.d < ? AND e.confidence >= ?
209
+ )
210
+ SELECT n.*, min(up.d) AS depth, max(up.conf) AS confidence
211
+ FROM up JOIN node n ON n.id = up.id
212
+ WHERE up.d > 0 GROUP BY n.id ORDER BY depth, confidence DESC LIMIT 500
213
+ """,
214
+ (nid, depth, min_confidence),
215
+ ).fetchall()
216
+ by_depth: dict[int, list] = {}
217
+ for r in rows:
218
+ by_depth.setdefault(r["depth"], []).append(self._slim(r))
219
+ files = sorted({r["path"] for r in rows if r["path"]})
220
+ out = {
221
+ "target": dict(matches[0]),
222
+ "total": len(rows),
223
+ "by_depth": {d: v[:per_depth] for d, v in by_depth.items()},
224
+ "files_affected": files,
225
+ }
226
+ dropped = {d: len(v) - per_depth for d, v in by_depth.items() if len(v) > per_depth}
227
+ if dropped: # never truncate silently: an agent must know the list is partial
228
+ out["truncated"] = {
229
+ "hidden_per_depth": dropped,
230
+ "hint": "raise per_depth, raise min_confidence, or query `edge` directly for the rest",
231
+ }
232
+ return out
233
+
234
+ def trace(self, src: str, dst: str, max_depth: int = 8, min_confidence: float = 0.5) -> dict:
235
+ """Shortest directed path between two symbols. A bare name that matches several
236
+ symbols is searched from/to all of them, so `trace execute save` finds the one
237
+ `execute` that actually reaches `save`."""
238
+ a, b = self.resolve(src), self.resolve(dst)
239
+ if not a or not b:
240
+ missing = src if not a else dst
241
+ return {"error": f"unresolved: {missing!r}", "suggestions": self.search(missing, 5)}
242
+ starts = [r["id"] for r in a[:10]]
243
+ ends = [r["id"] for r in b[:10]]
244
+ kinds = ",".join(f"'{k}'" for k in TRAVERSABLE)
245
+ row = self.db.execute(
246
+ f"""
247
+ WITH RECURSIVE p(id, path, d) AS (
248
+ SELECT id, '>' || id || '>', 0 FROM node WHERE id IN ({",".join("?" * len(starts))})
249
+ UNION ALL
250
+ SELECT e.dst, p.path || e.dst || '>', p.d + 1
251
+ FROM edge e JOIN p ON e.src = p.id
252
+ WHERE p.d < ? AND e.kind IN ({kinds}) AND e.confidence >= ?
253
+ AND instr(p.path, '>' || e.dst || '>') = 0
254
+ )
255
+ SELECT path, d FROM p WHERE id IN ({",".join("?" * len(ends))}) ORDER BY d LIMIT 1
256
+ """,
257
+ (*starts, max_depth, min_confidence, *ends),
258
+ ).fetchone()
259
+ if row is None:
260
+ return {"found": False, "from": dict(a[0]), "to": dict(b[0])}
261
+ ids = [int(x) for x in row["path"].strip(">").split(">")]
262
+ placeholders = ",".join("?" * len(ids))
263
+ nodes = {
264
+ r["id"]: self._slim(r) for r in self.db.execute(f"SELECT * FROM node WHERE id IN ({placeholders})", ids)
265
+ }
266
+ return {"found": True, "length": row["d"], "path": [nodes[i] for i in ids]}
267
+
268
+ def stats(self) -> dict:
269
+ nodes = {r["kind"]: r["n"] for r in self.db.execute("SELECT kind, count(*) n FROM node GROUP BY kind")}
270
+ edges = {r["kind"]: r["n"] for r in self.db.execute("SELECT kind, count(*) n FROM edge GROUP BY kind")}
271
+ langs = {
272
+ r["lang"]: r["n"]
273
+ for r in self.db.execute(
274
+ "SELECT lang, count(*) n FROM node WHERE lang IS NOT NULL AND kind='File' GROUP BY lang ORDER BY n DESC"
275
+ )
276
+ }
277
+ return {"nodes": nodes, "edges": edges, "languages": langs, **self.meta()}
278
+
279
+ def query(self, sql: str, limit: int = 100) -> list[dict]:
280
+ """Read-only escape hatch. The schema is small enough to query directly."""
281
+ stripped = sql.lstrip().lower()
282
+ if not (stripped.startswith("select") or stripped.startswith("with")):
283
+ raise ValueError("only SELECT / WITH queries are allowed")
284
+ if ";" in sql.rstrip().rstrip(";"):
285
+ raise ValueError("only a single statement is allowed")
286
+ return [dict(r) for r in self.db.execute(sql).fetchmany(limit)]