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/mcp_server.py ADDED
@@ -0,0 +1,157 @@
1
+ # Copyright 2026 The Heimdall Authors
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """MCP server. One process serves every repository in the registry.
4
+
5
+ Tools return precomputed relational answers -- callers grouped by depth, a resolved
6
+ path, the source of a symbol -- not raw edges for the model to re-assemble.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ from contextlib import closing as _closing
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ try: # the SDK renamed FastMCP to MCPServer; the decorator API is identical
17
+ from mcp.server.mcpserver import MCPServer as _Server
18
+ except ImportError: # pragma: no cover - older mcp releases
19
+ from mcp.server.fastmcp import FastMCP as _Server
20
+
21
+ from . import registry
22
+ from .graph import Graph
23
+
24
+ mcp = _Server("heimdall")
25
+ _DEFAULT: str | None = None
26
+
27
+
28
+ def _open(project: str | None) -> Graph:
29
+ return Graph.open(registry.resolve(project or _DEFAULT), readonly=True)
30
+
31
+
32
+ def _json(data: Any) -> str:
33
+ # compact: indentation is pure token cost to the model reading this
34
+ return json.dumps(data, separators=(",", ":"), default=str)
35
+
36
+
37
+ @mcp.tool()
38
+ def search(query: str, kind: str = "", limit: int = 20, project: str = "") -> str:
39
+ """Find symbols by name or signature, BM25 ranked. Start here when you only know
40
+ roughly what you are looking for.
41
+
42
+ kind: optional filter -- Function, Method, Class, Interface or File.
43
+ """
44
+ with _closing(_open(project)) as g:
45
+ return _json(g.search(query, limit, kind or None))
46
+
47
+
48
+ @mcp.tool()
49
+ def context(symbol: str, project: str = "") -> str:
50
+ """Everything about one symbol: where it is defined, its members, what it calls, who
51
+ calls it, and its supertypes. `symbol` may be a bare name, a qualified name
52
+ (`path/file.py:Class.method`) or a file path.
53
+ """
54
+ with _closing(_open(project)) as g:
55
+ return _json(g.context(symbol))
56
+
57
+
58
+ @mcp.tool()
59
+ def impact(symbol: str, depth: int = 3, min_confidence: float = 0.5, per_depth: int = 40, project: str = "") -> str:
60
+ """Blast radius: every symbol that transitively reaches this one, grouped by call
61
+ depth, plus the list of files affected. Use before changing or deleting anything.
62
+
63
+ min_confidence: call edges are scored 0.3 (name match only) to 1.0 (certain);
64
+ raise it to drop speculative edges.
65
+ """
66
+ with _closing(_open(project)) as g:
67
+ return _json(g.impact(symbol, depth, min_confidence, per_depth))
68
+
69
+
70
+ @mcp.tool()
71
+ def trace(source: str, target: str, max_depth: int = 8, min_confidence: float = 0.5, project: str = "") -> str:
72
+ """Shortest call/import/inheritance path from one symbol to another, or a statement
73
+ that none exists. Use to answer "how does A end up reaching B?".
74
+ """
75
+ with _closing(_open(project)) as g:
76
+ return _json(g.trace(source, target, max_depth, min_confidence))
77
+
78
+
79
+ @mcp.tool()
80
+ def overview(project: str = "") -> str:
81
+ """Shape of the repository: node and edge counts, languages, the most-imported files
82
+ and the most-called symbols. Use this first when you do not know the codebase.
83
+ """
84
+ with _closing(_open(project)) as g:
85
+ data = g.stats()
86
+ data["most_imported_files"] = g.query(
87
+ "SELECT n.path, count(*) AS importers FROM edge e JOIN node n ON n.id = e.dst"
88
+ " WHERE e.kind = 'IMPORTS' GROUP BY n.id ORDER BY importers DESC LIMIT 15"
89
+ )
90
+ data["most_called_symbols"] = g.query(
91
+ "SELECT n.qname, n.path, n.line, count(*) AS callers FROM edge e"
92
+ " JOIN node n ON n.id = e.dst WHERE e.kind = 'CALLS' AND e.confidence >= 0.7"
93
+ " GROUP BY n.id ORDER BY callers DESC LIMIT 15"
94
+ )
95
+ data["entry_points"] = g.query(
96
+ "SELECT n.qname, n.path, n.line FROM node n WHERE n.kind IN ('Function','Method')"
97
+ " AND n.name IN ('main','execute','run','handler','handle','serve','start')"
98
+ " ORDER BY n.path LIMIT 25"
99
+ )
100
+ return _json(data)
101
+
102
+
103
+ @mcp.tool()
104
+ def read_symbol(symbol: str, project: str = "") -> str:
105
+ """The actual source code of a symbol, read from disk. Cheaper than reading the whole
106
+ file when you only need one function or class.
107
+ """
108
+ with _closing(_open(project)) as g:
109
+ matches = g.resolve(symbol)
110
+ if not matches:
111
+ return _json({"error": f"no symbol matching {symbol!r}", "suggestions": g.search(symbol, 5)})
112
+ n = matches[0]
113
+ root = Path(g.meta().get("root", "."))
114
+ try:
115
+ lines = (root / n["path"]).read_text(encoding="utf-8", errors="replace").splitlines()
116
+ except OSError as exc:
117
+ return _json({"error": str(exc)})
118
+ start, end = max(0, n["line"] - 1), (n["end_line"] or n["line"]) or len(lines)
119
+ body = lines[start:end] if n["kind"] not in ("File", "Folder") else lines
120
+ return _json(
121
+ {
122
+ "qname": n["qname"],
123
+ "path": n["path"],
124
+ "lines": f"{start + 1}-{start + len(body)}",
125
+ "source": "\n".join(body),
126
+ }
127
+ )
128
+
129
+
130
+ @mcp.tool()
131
+ def sql(query: str, limit: int = 100, project: str = "") -> str:
132
+ """Read-only SQL against the graph, for questions the other tools do not cover.
133
+
134
+ Schema:
135
+ node(id, kind, name, qname, path, line, end_line, lang, signature)
136
+ kind: Folder | File | Class | Interface | Function | Method
137
+ edge(src, dst, kind, confidence, line)
138
+ kind: CONTAINS | DEFINES | IMPORTS | CALLS | EXTENDS
139
+ Recursive CTEs work; only a single SELECT or WITH statement is accepted.
140
+ """
141
+ with _closing(_open(project)) as g:
142
+ try:
143
+ return _json(g.query(query, limit))
144
+ except Exception as exc:
145
+ return _json({"error": f"{type(exc).__name__}: {exc}"})
146
+
147
+
148
+ @mcp.tool()
149
+ def projects() -> str:
150
+ """Repositories that have been indexed and can be passed as `project`."""
151
+ return _json(registry.load())
152
+
153
+
154
+ def serve(project: str | None = None) -> None:
155
+ global _DEFAULT
156
+ _DEFAULT = project
157
+ mcp.run()
heimdall/parse.py ADDED
@@ -0,0 +1,409 @@
1
+ # Copyright 2026 The Heimdall Authors
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Tree-sitter symbol extraction.
4
+
5
+ Language support is a data table, not code: node-type -> symbol kind. Tree-sitter
6
+ grammars name their nodes consistently enough that one generic walker covers every
7
+ language here. Adding a language means adding rows, not a module.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from dataclasses import dataclass, field
13
+
14
+ from tree_sitter_language_pack import get_parser
15
+
16
+ # ---------------------------------------------------------------- language data
17
+
18
+ EXT_LANG = {
19
+ ".py": "python",
20
+ ".pyi": "python",
21
+ ".js": "javascript",
22
+ ".jsx": "javascript",
23
+ ".mjs": "javascript",
24
+ ".cjs": "javascript",
25
+ ".ts": "typescript",
26
+ ".tsx": "tsx",
27
+ ".go": "go",
28
+ ".rs": "rust",
29
+ ".java": "java",
30
+ ".kt": "kotlin",
31
+ ".kts": "kotlin",
32
+ ".rb": "ruby",
33
+ ".php": "php",
34
+ ".cs": "csharp",
35
+ ".swift": "swift",
36
+ ".scala": "scala",
37
+ ".c": "c",
38
+ ".h": "c",
39
+ ".cc": "cpp",
40
+ ".cpp": "cpp",
41
+ ".cxx": "cpp",
42
+ ".hpp": "cpp",
43
+ ".lua": "lua",
44
+ ".dart": "dart",
45
+ ".sh": "bash",
46
+ }
47
+
48
+ _JS_DEFS = {
49
+ "function_declaration": "Function",
50
+ "generator_function_declaration": "Function",
51
+ "class_declaration": "Class",
52
+ "method_definition": "Method",
53
+ }
54
+ _TS_DEFS = _JS_DEFS | {
55
+ "interface_declaration": "Interface",
56
+ "abstract_class_declaration": "Class",
57
+ "enum_declaration": "Class",
58
+ "type_alias_declaration": "Interface",
59
+ "function_signature": "Function",
60
+ }
61
+
62
+ # node type -> symbol kind. A Function nested directly in a Class becomes a Method.
63
+ DEFS: dict[str, dict[str, str]] = {
64
+ "python": {"function_definition": "Function", "class_definition": "Class"},
65
+ "javascript": _JS_DEFS,
66
+ "typescript": _TS_DEFS,
67
+ "tsx": _TS_DEFS,
68
+ "go": {"function_declaration": "Function", "method_declaration": "Method", "type_spec": "Class"},
69
+ "rust": {
70
+ "function_item": "Function",
71
+ "struct_item": "Class",
72
+ "trait_item": "Interface",
73
+ "enum_item": "Class",
74
+ "impl_item": "Class",
75
+ },
76
+ "java": {
77
+ "class_declaration": "Class",
78
+ "interface_declaration": "Interface",
79
+ "method_declaration": "Method",
80
+ "constructor_declaration": "Method",
81
+ "enum_declaration": "Class",
82
+ "record_declaration": "Class",
83
+ },
84
+ "kotlin": {"class_declaration": "Class", "function_declaration": "Function", "object_declaration": "Class"},
85
+ "csharp": {
86
+ "class_declaration": "Class",
87
+ "interface_declaration": "Interface",
88
+ "method_declaration": "Method",
89
+ "struct_declaration": "Class",
90
+ "record_declaration": "Class",
91
+ "constructor_declaration": "Method",
92
+ },
93
+ "ruby": {"class": "Class", "module": "Class", "method": "Method", "singleton_method": "Method"},
94
+ "php": {
95
+ "class_declaration": "Class",
96
+ "interface_declaration": "Interface",
97
+ "function_definition": "Function",
98
+ "method_declaration": "Method",
99
+ "trait_declaration": "Interface",
100
+ },
101
+ "c": {"function_definition": "Function", "struct_specifier": "Class"},
102
+ "cpp": {
103
+ "function_definition": "Function",
104
+ "class_specifier": "Class",
105
+ "struct_specifier": "Class",
106
+ "namespace_definition": "Class",
107
+ },
108
+ "swift": {"class_declaration": "Class", "function_declaration": "Function", "protocol_declaration": "Interface"},
109
+ "scala": {
110
+ "class_definition": "Class",
111
+ "object_definition": "Class",
112
+ "trait_definition": "Interface",
113
+ "function_definition": "Function",
114
+ },
115
+ "lua": {"function_declaration": "Function"},
116
+ "dart": {"class_definition": "Class", "function_signature": "Function", "method_signature": "Method"},
117
+ "bash": {"function_definition": "Function"},
118
+ }
119
+
120
+ CALL_NODES: dict[str, set[str]] = {
121
+ "python": {"call"},
122
+ "javascript": {"call_expression", "new_expression"},
123
+ "typescript": {"call_expression", "new_expression"},
124
+ "tsx": {"call_expression", "new_expression"},
125
+ "go": {"call_expression"},
126
+ "rust": {"call_expression", "macro_invocation"},
127
+ "java": {"method_invocation", "object_creation_expression"},
128
+ "kotlin": {"call_expression"},
129
+ "csharp": {"invocation_expression", "object_creation_expression"},
130
+ "ruby": {"call", "method_call"},
131
+ "php": {"function_call_expression", "member_call_expression", "scoped_call_expression"},
132
+ "c": {"call_expression"},
133
+ "cpp": {"call_expression"},
134
+ "swift": {"call_expression"},
135
+ "scala": {"call_expression"},
136
+ "lua": {"function_call"},
137
+ "dart": {"selector"},
138
+ "bash": {"command"},
139
+ }
140
+
141
+ IMPORT_NODES: dict[str, set[str]] = {
142
+ "python": {"import_statement", "import_from_statement"},
143
+ "javascript": {"import_statement", "export_statement"},
144
+ "typescript": {"import_statement", "export_statement"},
145
+ "tsx": {"import_statement", "export_statement"},
146
+ "go": {"import_spec"},
147
+ "rust": {"use_declaration"},
148
+ "java": {"import_declaration"},
149
+ "kotlin": {"import_header"},
150
+ "csharp": {"using_directive"},
151
+ "ruby": set(),
152
+ "php": {"namespace_use_declaration"},
153
+ "c": {"preproc_include"},
154
+ "cpp": {"preproc_include"},
155
+ "swift": {"import_declaration"},
156
+ "scala": {"import_declaration"},
157
+ "lua": set(),
158
+ "dart": {"configurable_uri"},
159
+ "bash": set(),
160
+ }
161
+
162
+ # where a base class / implemented interface hides: a named field on some grammars,
163
+ # a plain child node on others
164
+ HERITAGE_FIELDS = ("superclasses", "superclass", "super_interfaces", "interfaces")
165
+ HERITAGE_TYPES = {
166
+ "class_heritage",
167
+ "superclass",
168
+ "super_interfaces",
169
+ "extends_clause",
170
+ "implements_clause",
171
+ "base_class_clause",
172
+ "interface_type_list",
173
+ "base_list",
174
+ "delegation_specifier",
175
+ "extends_type_clause",
176
+ "inheritance_specifier",
177
+ }
178
+
179
+ # `from x import y as z` / `import { y as z }`: the call site says `z`, the symbol is `y`.
180
+ # Both grammars spell it with `name` and `alias` fields, so one set covers both.
181
+ # ponytail: Python and the JS family, where renaming on import is idiomatic. Go and
182
+ # Kotlin spell it differently; add their node types here if their edges start to matter.
183
+ ALIAS_NODES = {"aliased_import", "import_specifier"}
184
+
185
+ _NAME_FIELDS = ("name", "declarator", "type", "path")
186
+ _MODULE_FIELDS = ("module_name", "source", "path", "name")
187
+ _KEYWORDS = {"import", "from", "use", "using", "require", "include", "export", "package", ";", "#include"}
188
+
189
+
190
+ # ---------------------------------------------------------------- data classes
191
+
192
+
193
+ @dataclass(slots=True)
194
+ class Symbol:
195
+ kind: str
196
+ name: str
197
+ qname: str
198
+ line: int
199
+ end_line: int
200
+ parent: str | None
201
+ signature: str
202
+
203
+
204
+ @dataclass(slots=True)
205
+ class FileParse:
206
+ path: str
207
+ lang: str
208
+ symbols: list[Symbol] = field(default_factory=list)
209
+ # (caller_qname_or_None, callee_name, line, receiver_or_None)
210
+ calls: list[tuple[str | None, str, int, str | None]] = field(default_factory=list)
211
+ # (module, line, enclosing_symbol_qname_or_None) -- the scope matters: a function
212
+ # that does its own `from x import y` must not be resolved against the whole file
213
+ imports: list[tuple[str, int, str | None]] = field(default_factory=list)
214
+ # (local_name, original_name, line_of_its_import) for renamed imports
215
+ renames: list[tuple[str, str, int]] = field(default_factory=list)
216
+ # (class_qname, base_name)
217
+ heritage: list[tuple[str, str]] = field(default_factory=list)
218
+ error: str | None = None
219
+
220
+
221
+ # ---------------------------------------------------------------- helpers
222
+
223
+
224
+ def _text(node) -> str:
225
+ return node.text.decode("utf-8", "replace")
226
+
227
+
228
+ def _name_of(node) -> str | None:
229
+ for f in _NAME_FIELDS:
230
+ n = node.child_by_field_name(f)
231
+ if n is not None:
232
+ t = _text(n)
233
+ # C declarators carry the params; keep the identifier only.
234
+ return t.split("(")[0].strip("*& ") or None
235
+ # rust `impl Foo`, ruby `class Foo` and friends expose the name positionally.
236
+ for c in node.children:
237
+ if c.type in ("identifier", "type_identifier", "constant", "field_identifier", "name"):
238
+ return _text(c)
239
+ return None
240
+
241
+
242
+ def _callee_of(node) -> tuple[str | None, str | None]:
243
+ """(receiver, name) for a call. `self.n()` -> ("self", "n"), `foo()` -> (None, "foo")."""
244
+ if node.type == "selector":
245
+ # dart puts the callee and its argument list side by side, not nested
246
+ if not any(c.type == "argument_part" for c in node.children):
247
+ return None, None
248
+ prev = node.prev_named_sibling
249
+ return (None, _text(prev).lstrip(".")) if prev is not None else (None, None)
250
+ n = node.child_by_field_name("function") or node.child_by_field_name("name") or node.child_by_field_name("method")
251
+ if n is None:
252
+ for c in node.children:
253
+ if c.type in (
254
+ "identifier",
255
+ "simple_identifier",
256
+ "field_expression",
257
+ "selector_expression",
258
+ "member_expression",
259
+ "navigation_expression",
260
+ "attribute",
261
+ "scoped_identifier",
262
+ ):
263
+ n = c
264
+ break
265
+ if n is None:
266
+ return None, None
267
+ parts = _text(n).replace("::", ".").split(".")
268
+ name = parts[-1].strip()
269
+ receiver = parts[-2].strip() if len(parts) > 1 else None
270
+ return receiver or None, name or None
271
+
272
+
273
+ def _module_of(node) -> str | None:
274
+ # `export class Foo ...` is not an import; only `export ... from "x"` is.
275
+ if node.type.endswith("export_statement"):
276
+ src = node.child_by_field_name("source")
277
+ return _text(src).strip("\"'<>") if src is not None else None
278
+ for f in _MODULE_FIELDS:
279
+ n = node.child_by_field_name(f)
280
+ if n is not None:
281
+ return _clean_module(_text(n))
282
+ for c in node.children:
283
+ if _text(c) in _KEYWORDS or not c.is_named:
284
+ continue
285
+ return _clean_module(_text(c))
286
+ return None
287
+
288
+
289
+ def _renames(import_node) -> list[tuple[str, str]]:
290
+ """(local, original) for every `as` rename inside one import statement."""
291
+ found: list[tuple[str, str]] = []
292
+
293
+ def walk(n):
294
+ if n.type in ALIAS_NODES:
295
+ orig, local = n.child_by_field_name("name"), n.child_by_field_name("alias")
296
+ if orig is not None and local is not None:
297
+ found.append((_text(local).strip(), _text(orig).split(".")[-1].strip()))
298
+ return
299
+ for c in n.children:
300
+ walk(c)
301
+
302
+ walk(import_node)
303
+ return found
304
+
305
+
306
+ def _clean_module(text: str) -> str | None:
307
+ """A module path is a single token; anything with whitespace is a mis-parse."""
308
+ text = text.strip().strip("\"'<>")
309
+ return text if text and not any(c.isspace() for c in text) else None
310
+
311
+
312
+ def _signature(node, src: bytes) -> str:
313
+ """First line of the declaration, which is the signature for every language here."""
314
+ end = src.find(b"\n", node.start_byte)
315
+ end = node.end_byte if end == -1 else min(end, node.end_byte)
316
+ return src[node.start_byte : end].decode("utf-8", "replace").strip()[:400]
317
+
318
+
319
+ # ---------------------------------------------------------------- the walker
320
+
321
+
322
+ def parse_source(path: str, lang: str, src: bytes) -> FileParse:
323
+ out = FileParse(path=path, lang=lang)
324
+ defs = DEFS.get(lang, {})
325
+ calls = CALL_NODES.get(lang, set())
326
+ imports = IMPORT_NODES.get(lang, set())
327
+ try:
328
+ tree = get_parser(lang).parse(src)
329
+ except Exception as exc: # unparsable file: record and keep indexing the repo
330
+ out.error = f"{type(exc).__name__}: {exc}"
331
+ return out
332
+
333
+ def visit(node, parent: Symbol | None) -> Symbol | None:
334
+ # Python hangs the decorators and the `def` off one node, decorators first. Visit
335
+ # the def so its Symbol exists, then the decorators against it: `@setup(...)` is a
336
+ # call by the function it wraps, not by the class that happens to enclose it.
337
+ # ponytail: other languages nest decorators *inside* the definition node, where
338
+ # the walk already gets the parent right.
339
+ if node.type == "decorated_definition":
340
+ inner = node.child_by_field_name("definition")
341
+ sym = visit(inner, parent) if inner is not None else None
342
+ for c in node.children:
343
+ if c.type == "decorator":
344
+ visit(c, sym or parent)
345
+ return sym
346
+
347
+ kind = defs.get(node.type)
348
+ if kind is not None:
349
+ name = _name_of(node)
350
+ if name:
351
+ if kind == "Function" and parent is not None and parent.kind in ("Class", "Interface"):
352
+ kind = "Method"
353
+ qname = f"{parent.qname}.{name}" if parent else f"{path}:{name}"
354
+ sym = Symbol(
355
+ kind=kind,
356
+ name=name,
357
+ qname=qname,
358
+ line=node.start_point[0] + 1,
359
+ end_line=node.end_point[0] + 1,
360
+ parent=parent.qname if parent else None,
361
+ signature=_signature(node, src),
362
+ )
363
+ out.symbols.append(sym)
364
+ if kind in ("Class", "Interface"):
365
+ clauses = [node.child_by_field_name(f) for f in HERITAGE_FIELDS]
366
+ clauses += [c for c in node.children if c.type in HERITAGE_TYPES]
367
+ bases: list[str] = []
368
+ for h in clauses:
369
+ if h is not None:
370
+ bases += [b for b in _bases(h) if b not in bases]
371
+ out.heritage += [(qname, b) for b in bases]
372
+ for c in node.children:
373
+ visit(c, sym)
374
+ return sym
375
+ elif node.type in calls:
376
+ receiver, callee = _callee_of(node)
377
+ if callee and callee.isidentifier():
378
+ out.calls.append((parent.qname if parent else None, callee, node.start_point[0] + 1, receiver))
379
+ elif node.type in imports:
380
+ mod = _module_of(node)
381
+ if mod:
382
+ line = node.start_point[0] + 1
383
+ out.imports.append((mod, line, parent.qname if parent else None))
384
+ # keyed by the statement's line so the resolver can tell whether the
385
+ # module it came from is even in this repo
386
+ out.renames += [(local, orig, line) for local, orig in _renames(node)]
387
+ for c in node.children:
388
+ visit(c, parent)
389
+ return None
390
+
391
+ visit(tree.root_node, None)
392
+ return out
393
+
394
+
395
+ def _bases(heritage_node) -> list[str]:
396
+ """Identifiers inside a heritage clause, e.g. `extends B implements I` -> [B, I]."""
397
+ found: list[str] = []
398
+
399
+ def walk(n):
400
+ if n.type in ("identifier", "type_identifier", "constant", "scoped_type_identifier", "generic_type"):
401
+ t = _text(n).split("<")[0].split(".")[-1].strip()
402
+ if t and t.isidentifier():
403
+ found.append(t)
404
+ return
405
+ for c in n.children:
406
+ walk(c)
407
+
408
+ walk(heritage_node)
409
+ return found
heimdall/registry.py ADDED
@@ -0,0 +1,51 @@
1
+ # Copyright 2026 The Heimdall Authors
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Which repositories have been indexed, so one MCP server can serve all of them."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import json
8
+ import os
9
+ from pathlib import Path
10
+
11
+ HOME = Path(os.environ.get("HEIMDALL_HOME") or Path.home() / ".heimdall")
12
+ REGISTRY = HOME / "registry.json"
13
+
14
+
15
+ def db_path_for(root: Path) -> Path:
16
+ return root.resolve() / ".heimdall" / "graph.db"
17
+
18
+
19
+ def load() -> dict[str, str]:
20
+ try:
21
+ return json.loads(REGISTRY.read_text())
22
+ except (OSError, ValueError):
23
+ return {}
24
+
25
+
26
+ def register(name: str, db: Path) -> None:
27
+ entries = load()
28
+ entries[name] = str(db.resolve())
29
+ REGISTRY.parent.mkdir(parents=True, exist_ok=True)
30
+ REGISTRY.write_text(json.dumps(entries, indent=2, sort_keys=True))
31
+
32
+
33
+ def resolve(project: str | None, cwd: Path | None = None) -> Path:
34
+ """A project name, an explicit db path, or the repo in the current directory."""
35
+ if project:
36
+ p = Path(project)
37
+ if p.suffix == ".db" and p.exists():
38
+ return p
39
+ entries = load()
40
+ if project in entries:
41
+ return Path(entries[project])
42
+ matches = [v for k, v in entries.items() if project in k]
43
+ if len(matches) == 1:
44
+ return Path(matches[0])
45
+ raise SystemExit(f"unknown project {project!r}; known: {', '.join(sorted(load())) or '(none)'}")
46
+ here = (cwd or Path.cwd()).resolve()
47
+ for d in (here, *here.parents):
48
+ db = d / ".heimdall" / "graph.db"
49
+ if db.exists():
50
+ return db
51
+ raise SystemExit("no index found here -- run `heimdall index` first, or pass --project")