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/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")
|