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 +54 -0
- heimdall/__init__.py +13 -0
- heimdall/__main__.py +8 -0
- heimdall/cli.py +200 -0
- heimdall/graph.py +286 -0
- heimdall/index.py +435 -0
- heimdall/install.py +151 -0
- heimdall/mcp_server.py +157 -0
- heimdall/parse.py +409 -0
- heimdall/registry.py +51 -0
- heimdall_code-0.1.0.dist-info/METADATA +293 -0
- heimdall_code-0.1.0.dist-info/RECORD +15 -0
- heimdall_code-0.1.0.dist-info/WHEEL +4 -0
- heimdall_code-0.1.0.dist-info/entry_points.txt +3 -0
- heimdall_code-0.1.0.dist-info/licenses/LICENSE +202 -0
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
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)]
|