heimdall-code 0.2.0__tar.gz → 0.3.1__tar.gz

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.
Files changed (33) hide show
  1. {heimdall_code-0.2.0 → heimdall_code-0.3.1}/.claude-plugin/plugin.json +1 -1
  2. {heimdall_code-0.2.0 → heimdall_code-0.3.1}/CHANGELOG.md +42 -0
  3. {heimdall_code-0.2.0 → heimdall_code-0.3.1}/CONTRIBUTING.md +16 -10
  4. {heimdall_code-0.2.0 → heimdall_code-0.3.1}/PKG-INFO +43 -9
  5. {heimdall_code-0.2.0 → heimdall_code-0.3.1}/README.md +41 -7
  6. {heimdall_code-0.2.0 → heimdall_code-0.3.1}/bench/README.md +49 -0
  7. {heimdall_code-0.2.0 → heimdall_code-0.3.1}/bench/RESULTS.md +5 -5
  8. heimdall_code-0.3.1/bench/kubernetes.py +295 -0
  9. {heimdall_code-0.2.0 → heimdall_code-0.3.1}/heimdall/__init__.py +1 -1
  10. {heimdall_code-0.2.0 → heimdall_code-0.3.1}/heimdall/cli.py +5 -1
  11. {heimdall_code-0.2.0 → heimdall_code-0.3.1}/heimdall/index.py +53 -7
  12. {heimdall_code-0.2.0 → heimdall_code-0.3.1}/heimdall/parse.py +188 -4
  13. {heimdall_code-0.2.0 → heimdall_code-0.3.1}/heimdall/registry.py +8 -3
  14. {heimdall_code-0.2.0 → heimdall_code-0.3.1}/pyproject.toml +1 -1
  15. {heimdall_code-0.2.0 → heimdall_code-0.3.1}/skills/heimdall/SKILL.md +1 -1
  16. {heimdall_code-0.2.0 → heimdall_code-0.3.1}/tests/test_heimdall.py +112 -0
  17. {heimdall_code-0.2.0 → heimdall_code-0.3.1}/uv.lock +1 -1
  18. {heimdall_code-0.2.0 → heimdall_code-0.3.1}/.claude-plugin/marketplace.json +0 -0
  19. {heimdall_code-0.2.0 → heimdall_code-0.3.1}/.github/workflows/release.yml +0 -0
  20. {heimdall_code-0.2.0 → heimdall_code-0.3.1}/.gitignore +0 -0
  21. {heimdall_code-0.2.0 → heimdall_code-0.3.1}/.mcp.json +0 -0
  22. {heimdall_code-0.2.0 → heimdall_code-0.3.1}/CLAUDE.md +0 -0
  23. {heimdall_code-0.2.0 → heimdall_code-0.3.1}/LICENSE +0 -0
  24. {heimdall_code-0.2.0 → heimdall_code-0.3.1}/bench/.gitignore +0 -0
  25. {heimdall_code-0.2.0 → heimdall_code-0.3.1}/bench/QUESTIONS.md +0 -0
  26. {heimdall_code-0.2.0 → heimdall_code-0.3.1}/bench/compare.py +0 -0
  27. {heimdall_code-0.2.0 → heimdall_code-0.3.1}/bench/dump_lbug.mjs +0 -0
  28. {heimdall_code-0.2.0 → heimdall_code-0.3.1}/bench/questions.py +0 -0
  29. {heimdall_code-0.2.0 → heimdall_code-0.3.1}/bench/terraform.py +0 -0
  30. {heimdall_code-0.2.0 → heimdall_code-0.3.1}/heimdall/__main__.py +0 -0
  31. {heimdall_code-0.2.0 → heimdall_code-0.3.1}/heimdall/graph.py +0 -0
  32. {heimdall_code-0.2.0 → heimdall_code-0.3.1}/heimdall/install.py +0 -0
  33. {heimdall_code-0.2.0 → heimdall_code-0.3.1}/heimdall/mcp_server.py +0 -0
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "heimdall",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
4
4
  "description": "A code knowledge graph that sees everything. Indexes any repo into a SQLite property graph and answers what calls this, what breaks if I change it, and how does A reach B.",
5
5
  "author": {
6
6
  "name": "Bogdan Sapovskyi",
@@ -3,6 +3,48 @@
3
3
  Notable changes, newest first. Versions follow [semver](https://semver.org); until 1.0
4
4
  the graph schema and the JSON payloads may still move.
5
5
 
6
+ ## 0.3.1 — 2026-08-12
7
+
8
+ ### Fixed
9
+
10
+ - A failed project lookup no longer takes the MCP server down with it. `search` with no
11
+ project, from a directory with no index, raised `SystemExit` — a `BaseException` the
12
+ tool handler never turned into an error reply, so it unwound the server's task group
13
+ and every later call found a dead pipe. `registry.resolve` raises `LookupError` now and
14
+ `cli.py` turns it back into `SystemExit` where a terminal is watching; the message and
15
+ exit code are unchanged there.
16
+
17
+ ## 0.3.0 — 2026-08-01
18
+
19
+ ### Added
20
+
21
+ - **Kubernetes manifests**, as the 20th language and the second one the generic walker
22
+ could not reach. A document with `apiVersion`, `kind` and `metadata.name` is indexed
23
+ under the address `kubectl` uses for it — `Deployment/api` — and every field holding
24
+ the name of another document becomes a `CALLS` edge. The field decides the kind: a name
25
+ under `configMapRef.name` can only be a ConfigMap, and where the field does not say
26
+ (`roleRef`, `scaleTargetRef`) the sibling `kind` does. So `impact ConfigMap/app-config`
27
+ now answers with the workloads that would restart.
28
+ - **Label selectors are edges too**, which is the one Kubernetes reference not written as
29
+ a name: a Service's selector is matched against the labels a workload puts on its pods,
30
+ within one namespace, so a prod Service cannot end up wired to staging pods. Scope
31
+ settles which copy was meant — a directory of manifests is a scope the way a Terraform
32
+ module is, and `kustomization.yaml`'s `resources` say which other directories join it.
33
+ - `bench/kubernetes.py`, graded by an oracle that re-reads the manifests by indentation
34
+ and shares no code with the tree-sitter reader. Across four public repos — 1,988 files,
35
+ 2,748 documents — every reference by name and by label resolves to the right document
36
+ and none of the edges is wrong. The one reference with no edge is `##CRBNAME##`, a sed
37
+ placeholder in an argo-cd test fixture, which is not a name.
38
+ - Yaml that is not a manifest is a file in the graph and nothing more. An unrendered Helm
39
+ chart is skipped rather than guessed at: `{{ .Release.Name }}-api` is not a name.
40
+
41
+ ### Fixed
42
+
43
+ - A yaml file is now parsed one document at a time, split on YAML's own `---` before the
44
+ grammar sees it. A single document the grammar could not follow used to take every
45
+ document after it down with it — in argo-cd's 1.9 MB rendered `install.yaml` that was
46
+ 82 of 130 objects, left out of the graph with nothing to say they were missing.
47
+
6
48
  ## 0.2.0 — 2026-08-01
7
49
 
8
50
  ### Added
@@ -18,7 +18,7 @@ There is no CI. The gate is `uv run pytest && uv run ruff check .` before you pu
18
18
 
19
19
  | | |
20
20
  |---|---|
21
- | `heimdall/parse.py` | tree-sitter → symbols, calls, imports, heritage. Language support is **data** (`EXT_LANG`, `DEFS`, `CALL_NODES`, `IMPORT_NODES`, `CONST_NODES`, `HERITAGE_*`), walked by one generic function. |
21
+ | `heimdall/parse.py` | tree-sitter → symbols, calls, imports, heritage. Language support is **data** (`EXT_LANG`, `DEFS`, `CALL_NODES`, `IMPORT_NODES`, `CONST_NODES`, `HERITAGE_*`), walked by one generic function — plus `_parse_hcl` and `_parse_yaml`, the two readers that could not be rows. |
22
22
  | `heimdall/index.py` | the four passes: discover → parse (process pool) → resolve → store. Everything a query needs is precomputed here. |
23
23
  | `heimdall/graph.py` | schema + reads. Two tables, recursive CTEs, FTS5. `query()` is the read-only SQL gate. |
24
24
  | `heimdall/cli.py` | argparse; every command takes `--json` and `-p/--project`. |
@@ -35,12 +35,13 @@ snippet in `SNIPPETS` in `tests/test_heimdall.py`. No new module. The test
35
35
  asserts the snippet still yields symbols *and* call edges, so a grammar renaming a node
36
36
  type fails the build instead of quietly emptying the graph.
37
37
 
38
- HCL is the one language that did not fit, and it is worth reading before deciding yours
39
- does not either: it has no definition nodes and no call nodes, so no set of rows could
40
- express it. It gets `_parse_hcl`, a reader beside the walker rather than a branch inside
41
- it, and its own three tables of Terraform addressing rules. That is the shape to copy if
42
- a language genuinely needs it — and the bar is "the grammar has nothing the walker is
43
- looking for", not "the walker gets a few nodes wrong".
38
+ HCL and YAML are the two that did not fit, and they are worth reading before deciding
39
+ yours does not either: neither has definition nodes or call nodes, so no set of rows could
40
+ express them. Each gets a reader beside the walker rather than a branch inside it —
41
+ `_parse_hcl` with its three tables of Terraform addressing rules, `_parse_yaml` with its
42
+ table of Kubernetes field paths — and the walker stays untouched. That is the shape to
43
+ copy if a language genuinely needs it, and the bar is "the grammar has nothing the walker
44
+ is looking for", not "the walker gets a few nodes wrong".
44
45
 
45
46
  ## Tests
46
47
 
@@ -56,9 +57,14 @@ resolution bug, add the smallest fixture that would have caught it.
56
57
  (it wants Node, npm and a clone of Django), but it is what a change to resolution should
57
58
  be measured with: `python bench/compare.py /path/to/django` and `python bench/questions.py`.
58
59
 
59
- `bench/terraform.py` does the same for HCL against a regex oracle — no other tool to
60
- compare against, so it only grades Heimdall's own edges: `python bench/terraform.py
61
- /path/to/some-terraform-repo`.
60
+ `bench/terraform.py` does the same for HCL against a regex oracle, and
61
+ `bench/kubernetes.py` for manifests against an oracle that re-reads them by indentation.
62
+ No other tool to compare against, so both only grade Heimdall's own edges:
63
+
64
+ ```console
65
+ python bench/terraform.py /path/to/some-terraform-repo
66
+ python bench/kubernetes.py /path/to/some-manifest-repo
67
+ ```
62
68
 
63
69
  ## Release
64
70
 
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: heimdall-code
3
- Version: 0.2.0
3
+ Version: 0.3.1
4
4
  Summary: A code knowledge graph that sees everything -- indexes any repository into a SQLite property graph and serves it to AI agents over MCP.
5
5
  Project-URL: Homepage, https://github.com/stPhoenix/heimdall
6
6
  Project-URL: Source, https://github.com/stPhoenix/heimdall
@@ -32,11 +32,11 @@ Apache-2.0. No server, no cloud, no embedded graph engine. **The graph is a SQLi
32
32
  ```console
33
33
  $ heimdall index ~/code/django
34
34
  pass 1/4 discovering files
35
- 2974 source files
35
+ 2998 source files
36
36
  pass 2/4 parsing (24 workers)
37
37
  pass 3/4 building graph
38
38
  pass 4/4 building search index
39
- done in 2.42s # 45,515 symbols, 418,925 edges
39
+ done in 2.35s # 45,515 symbols, 418,951 edges
40
40
 
41
41
  $ heimdall impact django/shortcuts.py:get_object_or_404 -d 2 -c 0.7
42
42
  impact of django/shortcuts.py:get_object_or_404: 10 symbols, 8 files
@@ -98,9 +98,9 @@ against [graphify](https://pypi.org/project/graphifyy/) in its AST-only mode:
98
98
  | live symbols wrongly shown as uncalled | **1** (0.0%) | 3,635 (52.3%) | 2,381 (34.2%) |
99
99
  | hand-written edge cases missed or fabricated | **1** of 13 | 4 of 13 | 3 of 13 |
100
100
  | *who calls `Model.check`*, precision | 94% | 0% | 95% |
101
- | index from scratch | **2.7 s** | 23.6 s | 29.8 s |
101
+ | index from scratch | **2.6 s** | 23.1 s | 28.6 s |
102
102
  | index on disk | **78 MB** | 730 MB | 151 MB |
103
- | `impact X` from cold | **58 ms** | 969 ms | 837 ms |
103
+ | `impact X` from cold | **59 ms** | 977 ms | 830 ms |
104
104
 
105
105
  Six of those Heimdall numbers only got there because the benchmark existed. It found that
106
106
  imports were resolved file-wide rather than per-scope, that a Python `pkg/main.py`
@@ -245,9 +245,9 @@ That is the whole data model — `heimdall sql` gives you direct access to it.
245
245
  ## Languages
246
246
 
247
247
  Python, JavaScript, TypeScript, TSX, Go, Rust, Java, Kotlin, C#, Ruby, PHP, C, C++,
248
- Swift, Scala, Lua, Dart, Bash, Terraform. Every one of them is covered by a test that
249
- asserts it still yields symbols and call edges, so a grammar rename fails the build
250
- rather than silently degrading the graph.
248
+ Swift, Scala, Lua, Dart, Bash, Terraform, Kubernetes. Every one of them is covered by a
249
+ test that asserts it still yields symbols and call edges, so a grammar rename fails the
250
+ build rather than silently degrading the graph.
251
251
 
252
252
  Adding a language means adding rows to four dicts in `heimdall/parse.py` — node types
253
253
  for definitions, for calls, for imports and for name bindings — plus a snippet in the test.
@@ -282,6 +282,40 @@ edges — one reference of 2,828 goes unresolved and none of the edges is wrong;
282
282
  Packer and Nomad `.hcl` parse with the same grammar and get symbols, but only Terraform's
283
283
  addressing rules are modelled.
284
284
 
285
+ ### Kubernetes
286
+
287
+ `.yaml` and `.yml` are read the same way and for the same reason. A document that carries
288
+ `apiVersion`, `kind` and `metadata.name` is indexed under the address `kubectl` uses for
289
+ it — `Deployment/api`, `ConfigMap/app-config` — and every field that holds the name of
290
+ another document becomes a `CALLS` edge. Which kind a name refers to is decided by the
291
+ field it sits in: the string under `configMapRef.name` can only be a ConfigMap, and where
292
+ the field does not say (`roleRef`, `scaleTargetRef`) the sibling `kind` does.
293
+
294
+ ```console
295
+ heimdall impact ConfigMap/app-config # every workload that would restart
296
+ heimdall context Deployment/api # what it mounts, and who routes to it
297
+ ```
298
+
299
+ A Service names no Deployment, so the one reference that is not written as a name is
300
+ matched by shape instead: a selector's labels against the labels a workload puts on its
301
+ pods, within one namespace. Scope settles the rest — a directory of manifests is a scope
302
+ like a Terraform module, and `kustomization.yaml`'s `resources` say which other
303
+ directories to add, so a `base` and its overlays resolve to the copy that was meant.
304
+
305
+ Scored against an oracle that re-reads the manifests by indentation, on four public repos
306
+ — 1,988 files, 2,748 documents — every reference by name and every reference by label
307
+ resolves to the right document, and none of the edges is wrong.
308
+
309
+ Yaml that is not a manifest is a file in the graph and nothing more; a Helm chart is not
310
+ yaml until it is rendered, and `{{ .Release.Name }}-api` is not a name, so unrendered
311
+ templates are skipped rather than guessed at.
312
+
313
+ One approximation worth knowing: a selector is matched against pod labels whatever kind
314
+ wrote it. A Prometheus `ServiceMonitor` selects *Services*, not pods, so Heimdall links it
315
+ to the workload whose pods carry those labels — the neighbouring object rather than the
316
+ exact one. Matching by kind would need a table of what every CRD selects; matching by
317
+ labels gets a CRD that selects pods right without knowing it exists.
318
+
285
319
  ## Limitations
286
320
 
287
321
  Call resolution is name-based, not type-based. `self.x()` and `module.x()` resolve
@@ -12,11 +12,11 @@ Apache-2.0. No server, no cloud, no embedded graph engine. **The graph is a SQLi
12
12
  ```console
13
13
  $ heimdall index ~/code/django
14
14
  pass 1/4 discovering files
15
- 2974 source files
15
+ 2998 source files
16
16
  pass 2/4 parsing (24 workers)
17
17
  pass 3/4 building graph
18
18
  pass 4/4 building search index
19
- done in 2.42s # 45,515 symbols, 418,925 edges
19
+ done in 2.35s # 45,515 symbols, 418,951 edges
20
20
 
21
21
  $ heimdall impact django/shortcuts.py:get_object_or_404 -d 2 -c 0.7
22
22
  impact of django/shortcuts.py:get_object_or_404: 10 symbols, 8 files
@@ -78,9 +78,9 @@ against [graphify](https://pypi.org/project/graphifyy/) in its AST-only mode:
78
78
  | live symbols wrongly shown as uncalled | **1** (0.0%) | 3,635 (52.3%) | 2,381 (34.2%) |
79
79
  | hand-written edge cases missed or fabricated | **1** of 13 | 4 of 13 | 3 of 13 |
80
80
  | *who calls `Model.check`*, precision | 94% | 0% | 95% |
81
- | index from scratch | **2.7 s** | 23.6 s | 29.8 s |
81
+ | index from scratch | **2.6 s** | 23.1 s | 28.6 s |
82
82
  | index on disk | **78 MB** | 730 MB | 151 MB |
83
- | `impact X` from cold | **58 ms** | 969 ms | 837 ms |
83
+ | `impact X` from cold | **59 ms** | 977 ms | 830 ms |
84
84
 
85
85
  Six of those Heimdall numbers only got there because the benchmark existed. It found that
86
86
  imports were resolved file-wide rather than per-scope, that a Python `pkg/main.py`
@@ -225,9 +225,9 @@ That is the whole data model — `heimdall sql` gives you direct access to it.
225
225
  ## Languages
226
226
 
227
227
  Python, JavaScript, TypeScript, TSX, Go, Rust, Java, Kotlin, C#, Ruby, PHP, C, C++,
228
- Swift, Scala, Lua, Dart, Bash, Terraform. Every one of them is covered by a test that
229
- asserts it still yields symbols and call edges, so a grammar rename fails the build
230
- rather than silently degrading the graph.
228
+ Swift, Scala, Lua, Dart, Bash, Terraform, Kubernetes. Every one of them is covered by a
229
+ test that asserts it still yields symbols and call edges, so a grammar rename fails the
230
+ build rather than silently degrading the graph.
231
231
 
232
232
  Adding a language means adding rows to four dicts in `heimdall/parse.py` — node types
233
233
  for definitions, for calls, for imports and for name bindings — plus a snippet in the test.
@@ -262,6 +262,40 @@ edges — one reference of 2,828 goes unresolved and none of the edges is wrong;
262
262
  Packer and Nomad `.hcl` parse with the same grammar and get symbols, but only Terraform's
263
263
  addressing rules are modelled.
264
264
 
265
+ ### Kubernetes
266
+
267
+ `.yaml` and `.yml` are read the same way and for the same reason. A document that carries
268
+ `apiVersion`, `kind` and `metadata.name` is indexed under the address `kubectl` uses for
269
+ it — `Deployment/api`, `ConfigMap/app-config` — and every field that holds the name of
270
+ another document becomes a `CALLS` edge. Which kind a name refers to is decided by the
271
+ field it sits in: the string under `configMapRef.name` can only be a ConfigMap, and where
272
+ the field does not say (`roleRef`, `scaleTargetRef`) the sibling `kind` does.
273
+
274
+ ```console
275
+ heimdall impact ConfigMap/app-config # every workload that would restart
276
+ heimdall context Deployment/api # what it mounts, and who routes to it
277
+ ```
278
+
279
+ A Service names no Deployment, so the one reference that is not written as a name is
280
+ matched by shape instead: a selector's labels against the labels a workload puts on its
281
+ pods, within one namespace. Scope settles the rest — a directory of manifests is a scope
282
+ like a Terraform module, and `kustomization.yaml`'s `resources` say which other
283
+ directories to add, so a `base` and its overlays resolve to the copy that was meant.
284
+
285
+ Scored against an oracle that re-reads the manifests by indentation, on four public repos
286
+ — 1,988 files, 2,748 documents — every reference by name and every reference by label
287
+ resolves to the right document, and none of the edges is wrong.
288
+
289
+ Yaml that is not a manifest is a file in the graph and nothing more; a Helm chart is not
290
+ yaml until it is rendered, and `{{ .Release.Name }}-api` is not a name, so unrendered
291
+ templates are skipped rather than guessed at.
292
+
293
+ One approximation worth knowing: a selector is matched against pod labels whatever kind
294
+ wrote it. A Prometheus `ServiceMonitor` selects *Services*, not pods, so Heimdall links it
295
+ to the workload whose pods carry those labels — the neighbouring object rather than the
296
+ exact one. Matching by kind would need a table of what every CRD selects; matching by
297
+ labels gets a CRD that selects pods right without knowing it exists.
298
+
265
299
  ## Limitations
266
300
 
267
301
  Call resolution is name-based, not type-based. `self.x()` and `module.x()` resolve
@@ -9,6 +9,7 @@ Two runners, because they measure different things:
9
9
  | `compare.py` | are the *edges* right, and what do they cost? | [RESULTS.md](RESULTS.md) |
10
10
  | `questions.py` | is the *answer* right? | [QUESTIONS.md](QUESTIONS.md) |
11
11
  | `terraform.py` | the same question for HCL, where the oracle is a regex | stdout |
12
+ | `kubernetes.py` | the same question for manifests, where the oracle reads indentation | stdout |
12
13
 
13
14
  The repository is [Django](https://github.com/django/django) — 2,927 Python files, deep
14
15
  inheritance, decorators everywhere, and `tests/` full of same-named methods, which is the
@@ -154,6 +155,50 @@ Bare resource references (`aws_s3_bucket.logs.arn`) are excluded from the recall
154
155
  no regex can tell one from an attribute lookup on something else — but the edges Heimdall
155
156
  emits for them are still graded for precision.
156
157
 
158
+ ## Kubernetes (`kubernetes.py`)
159
+
160
+ No competitor here either. The oracle reads every manifest a second time, by indentation,
161
+ sharing no code with the tree-sitter reader — what it cannot avoid sharing is the domain:
162
+ that `configMapRef.name` names a ConfigMap, and that a Service selects by label, are facts
163
+ about Kubernetes and both readers have to know them.
164
+
165
+ ```console
166
+ git clone --depth 1 https://github.com/GoogleCloudPlatform/microservices-demo
167
+ git clone --depth 1 https://github.com/kubernetes/examples
168
+ git clone --depth 1 https://github.com/prometheus-operator/kube-prometheus
169
+ git clone --depth 1 https://github.com/argoproj/argo-cd
170
+ python bench/kubernetes.py microservices-demo examples kube-prometheus argo-cd
171
+ ```
172
+
173
+ | repo | files | documents | refs by name | recall | selectors | recall | precision |
174
+ |---|---|---|---|---|---|---|---|
175
+ | microservices-demo | 54 | 149 | 1 | 100.0% | 2 | 100.0% | 100.0% |
176
+ | kubernetes/examples | 244 | 274 | 40 | 100.0% | 33 | 100.0% | 100.0% |
177
+ | kube-prometheus | 109 | 114 | 38 | 100.0% | 20 | 100.0% | 100.0% |
178
+ | argo-cd | 1,581 | 2,211 | 47 | 97.9% | 10 | 100.0% | 100.0% |
179
+ | **total** | **1,988** | **2,748** | **126** | **99.2%** | **65** | **100.0%** | **100.0%** |
180
+
181
+ *by name* — a field that names another object (`serviceAccountName`, `secretKeyRef.name`,
182
+ a `roleRef` whose sibling `kind` says what it is) must produce an edge to the document
183
+ that declares it. *by label* — where exactly one workload in the repo carries labels a
184
+ selector matches, that must be an edge too. *precision* — an edge is endorsed only if the
185
+ source really names the target, or really selects labels the target really carries in the
186
+ same namespace.
187
+
188
+ The one reference with no edge is `##CRBNAME##`, a sed placeholder in an argo-cd test
189
+ fixture. It is not a name, Heimdall is right to skip it, and the oracle is not clever
190
+ enough to know that — the row stays at 97.9% rather than being tuned to 100%.
191
+
192
+ Two things the oracle counts but does not score, because the right answer depends on
193
+ kustomize scope it does not model: 842 references whose name two or more files declare,
194
+ and 112 documents inside unrendered Helm charts, where `{{ .Release.Name }}-api` is not
195
+ a name that anything could resolve.
196
+
197
+ The oracle assumes `kubectl`-shaped yaml — two-space indents, `- key: value` list items.
198
+ Both of its own bugs found so far are pinned in its docstring: it first read correct edges
199
+ as wrong ones by collapsing repeated field paths into a dict, so a pod mounting six
200
+ ConfigMaps looked like it mounted one.
201
+
157
202
  ## Edge cases (`questions.py`, part 1)
158
203
 
159
204
  Cases 1–11 are aggregates over a real repository, which is the right way to measure the
@@ -225,5 +270,9 @@ Django's `_("...")`, i.e. `gettext` under its usual alias, of being invented.
225
270
  not a receiver the tool threw away.
226
271
  - Cases 12–27 are one repository of 29 hand-written files. They find shapes that break a
227
272
  resolver; they say nothing about how often those shapes occur.
273
+ - `kubernetes.py` cannot catch a modelling choice both readers share. Label matching is
274
+ generic — a `ServiceMonitor` selects Services rather than pods, and both this oracle and
275
+ Heimdall will pair it with the workload carrying those labels. That edge is endorsed
276
+ here and is still an approximation.
228
277
  - One repository, one language family. Django is a hard case for a name-based resolver,
229
278
  but it is still one data point — the runners take a `--repo` for a reason.
@@ -44,8 +44,8 @@ Same question on GitNexus's *own* graph (15,573 edges), where Leiden was actuall
44
44
 
45
45
  | | callers found | shared with the other | wall time |
46
46
  |---|---|---|---|
47
- | Heimdall (SQLite recursive CTE, in-process) | 2,063 | 1,904 | 14 ms |
48
- | GitNexus (LadybugDB Cypher, node subprocess) | 1,904 | 1,904 | 419 ms |
47
+ | Heimdall (SQLite recursive CTE, in-process) | 2,063 | 1,904 | 13 ms |
48
+ | GitNexus (LadybugDB Cypher, node subprocess) | 1,904 | 1,904 | 387 ms |
49
49
 
50
50
  Targets: `django/contrib/contenttypes/fields.py:create`, `django/contrib/sessions/backends/base.py:create`, `django/contrib/sessions/backends/cache.py:create`, `django/apps/config.py:create`, `django/test/testcases.py:assertRaisesMessage`
51
51
 
@@ -55,8 +55,8 @@ The two answers differ mostly because the graphs differ, not because the travers
55
55
 
56
56
  | | Heimdall | GitNexus | graphify |
57
57
  |---|---|---|---|
58
- | index from scratch | 2.7 s | 23.6 s | 29.8 s |
59
- | re-index, nothing changed | 2.7 s | 18.6 s | 29.8 s |
58
+ | index from scratch | 2.6 s | 23.1 s | 28.6 s |
59
+ | re-index, nothing changed | 2.6 s | 17.9 s | 28.7 s |
60
60
  | index on disk | 78 MB | 730 MB | 151 MB |
61
- | `impact create` from cold, median of 5 | 58 ms | 969 ms | 837 ms |
61
+ | `impact create` from cold, median of 5 | 59 ms | 977 ms | 830 ms |
62
62
 
@@ -0,0 +1,295 @@
1
+ # Copyright 2026 The Heimdall Authors
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Does Heimdall resolve Kubernetes references to the right document?
4
+
5
+ The oracle reads the manifests a second time, by indentation, and shares no code with the
6
+ tree-sitter reader -- so a mistake in the reader cannot make itself look right. What it
7
+ cannot share is the domain: which field names a ConfigMap, and that a Service selects by
8
+ label, are facts about Kubernetes, and both readers have to know them.
9
+
10
+ Two kinds of reference are graded separately, because they fail differently:
11
+
12
+ by name `configMapRef.name: app-config` names one object. Every such reference
13
+ whose target exactly one document in the repo declares must be an edge to
14
+ that document's file.
15
+ by label a Service names no Deployment -- it names the labels one puts on its pods.
16
+ Where exactly one workload in the repo carries a matching set, that must
17
+ be an edge too.
18
+
19
+ Precision covers both: an edge is endorsed only if the source document really names the
20
+ target, or really selects labels the target really carries in the same namespace.
21
+
22
+ References the oracle cannot adjudicate -- a name two files declare, a label set three
23
+ copies of an app answer to -- are counted and reported rather than scored, since the right
24
+ answer depends on kustomize scope that the oracle does not model.
25
+
26
+ The oracle assumes `kubectl`-shaped yaml: two-space indents, `- key: value` for list
27
+ items, one object per document. It cannot read an unrendered Helm chart, and neither can
28
+ anything else.
29
+
30
+ Usage:
31
+ python bench/kubernetes.py REPO [REPO ...]
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ import argparse
37
+ import sqlite3
38
+ import subprocess
39
+ import sys
40
+ import time
41
+ from collections import defaultdict
42
+ from pathlib import Path
43
+
44
+ # field path suffix -> the kind the value names; "*" means a sibling `kind` says which.
45
+ REFS = {
46
+ "serviceAccountName": "ServiceAccount",
47
+ "secretName": "Secret",
48
+ "claimName": "PersistentVolumeClaim",
49
+ "storageClassName": "StorageClass",
50
+ "ingressClassName": "IngressClass",
51
+ "priorityClassName": "PriorityClass",
52
+ "runtimeClassName": "RuntimeClass",
53
+ "volumeName": "PersistentVolume",
54
+ "serviceName": "Service",
55
+ "configMap.name": "ConfigMap",
56
+ "configMapRef.name": "ConfigMap",
57
+ "configMapKeyRef.name": "ConfigMap",
58
+ "secret.name": "Secret",
59
+ "secretRef.name": "Secret",
60
+ "secretKeyRef.name": "Secret",
61
+ "imagePullSecrets.name": "Secret",
62
+ "backend.service.name": "Service",
63
+ "roleRef.name": "*",
64
+ "subjects.name": "*",
65
+ "scaleTargetRef.name": "*",
66
+ "targetRef.name": "*",
67
+ "ownerReferences.name": "*",
68
+ }
69
+ SELECTORS = {("spec", "selector"), ("spec", "selector", "matchLabels"), ("spec", "podSelector", "matchLabels")}
70
+ BLOCKS = {"|", ">", "|-", ">-", "|+", ">+", "{}", "[]", "null", "~"}
71
+
72
+
73
+ def leaves(lines: list[str]) -> list[tuple[tuple[str, ...], str]]:
74
+ """(path, value) for every `key: value` in one document, by indentation alone.
75
+
76
+ List indices are dropped: the reader needs them to tell two containers apart, this
77
+ does not, and leaving them out is one more way for the two to disagree.
78
+ """
79
+ out: list[tuple[tuple[str, ...], str]] = []
80
+ stack: list[tuple[int, str]] = []
81
+ for raw in lines:
82
+ text = raw.rstrip()
83
+ if not text.strip() or text.lstrip().startswith("#"):
84
+ continue
85
+ indent = len(text) - len(text.lstrip())
86
+ body = text.lstrip()
87
+ while body.startswith("- "): # a list item opens a mapping two columns in
88
+ indent, body = indent + 2, body[2:]
89
+ if ":" not in body:
90
+ continue
91
+ key, _, val = body.partition(":")
92
+ key = key.strip().strip("\"'")
93
+ if not key or " " in key: # a url, or prose inside a block scalar
94
+ continue
95
+ val = val.strip().split(" #")[0].strip().strip("\"'")
96
+ while stack and stack[-1][0] >= indent:
97
+ stack.pop()
98
+ if val and val not in BLOCKS:
99
+ out.append(((*tuple(k for _, k in stack), key), val))
100
+ else:
101
+ stack.append((indent, key))
102
+ return out
103
+
104
+
105
+ def documents(text: str) -> list[list[str]]:
106
+ docs: list[list[str]] = [[]]
107
+ for line in text.splitlines():
108
+ if line.rstrip() == "---":
109
+ docs.append([])
110
+ else:
111
+ docs[-1].append(line)
112
+ return [d for d in docs if d]
113
+
114
+
115
+ def refs_of(multi: dict[tuple[str, ...], list[str]]) -> tuple[set[tuple[str, str]], int]:
116
+ """(kind, name) for every field that names another object, and the count of fields
117
+ that cannot be judged: without list indices, a `roleRef` whose sibling `kind` says
118
+ two different things cannot be paired back up with the right one."""
119
+ found, skipped = set(), 0
120
+ for path, values in multi.items():
121
+ for n in (3, 2, 1):
122
+ kind = REFS.get(".".join(path[-n:])) if len(path) >= n else None
123
+ if not kind:
124
+ continue
125
+ if kind == "*":
126
+ kinds = set(multi.get((*path[:-1], "kind"), ()))
127
+ if len(kinds) != 1:
128
+ skipped += len(values)
129
+ break
130
+ kind = kinds.pop()
131
+ found |= {(kind, v) for v in values if v}
132
+ break
133
+ return found, skipped
134
+
135
+
136
+ class Oracle:
137
+ def __init__(self, root: Path) -> None:
138
+ self.docs: dict[tuple[str, str], dict] = {}
139
+ self.declares: dict[str, list[str]] = defaultdict(list)
140
+ self.unrendered = self.unpaired = 0
141
+ files = [p for p in (*root.rglob("*.yaml"), *root.rglob("*.yml")) if ".git" not in p.parts]
142
+ for p in files:
143
+ rel = str(p.relative_to(root))
144
+ for lines in documents(p.read_text(errors="replace")):
145
+ # one path can hold many values -- a pod mounts six ConfigMaps at the same
146
+ # `volumes.configMap.name` once the list indices are gone -- so keep them
147
+ # all. Collapsing them into a dict is how this oracle first read a
148
+ # correct edge as a wrong one.
149
+ multi: dict[tuple[str, ...], list[str]] = defaultdict(list)
150
+ for path, value in leaves(lines):
151
+ multi[path].append(value)
152
+ vals = {p: v[0] for p, v in multi.items()}
153
+ kind, name = vals.get(("kind",)), vals.get(("metadata", "name"))
154
+ if not (("apiVersion",) in vals and kind and name):
155
+ continue
156
+ if "{{" in kind or "{{" in name:
157
+ # a Helm chart is not yaml until it is rendered, and `{{ .Values.x }}`
158
+ # is not a name. Counted rather than quietly dropped.
159
+ self.unrendered += 1
160
+ continue
161
+ labels = {
162
+ k[-1]: v
163
+ for k, v in vals.items()
164
+ if (
165
+ k[:-1] == ("metadata", "labels")
166
+ if kind == "Pod"
167
+ else k[-4:-1] == ("template", "metadata", "labels")
168
+ )
169
+ }
170
+ found, skipped = refs_of(multi)
171
+ self.unpaired += skipped
172
+ doc = {
173
+ "file": rel,
174
+ "addr": f"{kind}/{name}",
175
+ "ns": vals.get(("metadata", "namespace"), ""),
176
+ "refs": found,
177
+ "labels": labels,
178
+ "selector": {} if labels else {k[-1]: v for k, v in vals.items() if k[:-1] in SELECTORS},
179
+ }
180
+ self.docs.setdefault((rel, doc["addr"]), doc)
181
+ self.declares[doc["addr"]].append(rel)
182
+
183
+ def expected(self) -> tuple[dict[tuple[str, str, str], str], int, dict[tuple[str, str, str], str], int]:
184
+ """The edges that must exist, by name and by label, plus what cannot be judged."""
185
+ names: dict[tuple[str, str, str], str] = {}
186
+ labels: dict[tuple[str, str, str], str] = {}
187
+ blurry = blurry_sel = 0
188
+ for (rel, addr), doc in self.docs.items():
189
+ for kind, name in doc["refs"]:
190
+ target = f"{kind}/{name}"
191
+ owners = set(self.declares.get(target, ()))
192
+ if len(owners) == 1:
193
+ names[(rel, addr, target)] = owners.pop()
194
+ elif owners:
195
+ blurry += 1
196
+ if doc["selector"]:
197
+ hits = [
198
+ d
199
+ for d in self.docs.values()
200
+ if d["labels"] and d["ns"] == doc["ns"] and doc["selector"].items() <= d["labels"].items()
201
+ ]
202
+ if len(hits) == 1:
203
+ labels[(rel, addr, hits[0]["addr"])] = hits[0]["file"]
204
+ elif hits:
205
+ blurry_sel += 1
206
+ return names, blurry, labels, blurry_sel
207
+
208
+ def endorses(self, sfile: str, saddr: str, dfile: str, daddr: str) -> bool:
209
+ s, d = self.docs.get((sfile, saddr)), self.docs.get((dfile, daddr))
210
+ if s is None or d is None:
211
+ return False
212
+ kind, _, name = daddr.partition("/")
213
+ if (kind, name) in s["refs"]:
214
+ return True
215
+ return bool(s["selector"]) and s["ns"] == d["ns"] and s["selector"].items() <= d["labels"].items()
216
+
217
+
218
+ def edges(db: Path) -> list[tuple[str, str, str, str, float]]:
219
+ """(src file, src address, target file, target address, confidence) for yaml edges."""
220
+ con = sqlite3.connect(f"file:{db}?mode=ro", uri=True)
221
+ rows = con.execute(
222
+ "SELECT s.path, s.name, d.path, d.name, e.confidence FROM edge e"
223
+ " JOIN node s ON s.id = e.src JOIN node d ON d.id = e.dst"
224
+ " WHERE e.kind = 'CALLS' AND d.lang = 'yaml' AND s.lang = 'yaml'"
225
+ ).fetchall()
226
+ con.close()
227
+ return rows
228
+
229
+
230
+ def run(repo: Path, heimdall: str) -> dict:
231
+ db = repo / ".heimdall" / "graph.db"
232
+ t0 = time.time()
233
+ subprocess.run([heimdall, "index", str(repo)], check=True, capture_output=True)
234
+ secs = time.time() - t0
235
+
236
+ oracle = Oracle(repo)
237
+ names, blurry, labels, blurry_sel = oracle.expected()
238
+ found = edges(db)
239
+ keys = {(s, sa, da) for s, sa, _d, da, _c in found}
240
+ landed = {(s, sa, d, da) for s, sa, d, da, _c in found}
241
+ endorsed = [1 for s, sa, d, da, _c in found if oracle.endorses(s, sa, d, da)]
242
+
243
+ con = sqlite3.connect(f"file:{db}?mode=ro", uri=True)
244
+ docs = con.execute("SELECT count(*) FROM node WHERE lang='yaml' AND kind='Class'").fetchone()[0]
245
+ con.close()
246
+ hit = sum(1 for k, dst in names.items() if (*k[:2], dst, k[2]) in landed)
247
+ hit_sel = sum(1 for k, dst in labels.items() if (*k[:2], dst, k[2]) in landed)
248
+ return {
249
+ "repo": repo.name,
250
+ "files": len({d["file"] for d in oracle.docs.values()}),
251
+ "docs": docs,
252
+ "refs": len(names),
253
+ "hit": hit,
254
+ "sel": len(labels),
255
+ "hit_sel": hit_sel,
256
+ "edges": len(found),
257
+ "endorsed": len(endorsed),
258
+ "recall": hit / len(names) if names else 0.0,
259
+ "recall_sel": hit_sel / len(labels) if labels else 0.0,
260
+ "precision": len(endorsed) / len(found) if found else 0.0,
261
+ "conf": sum(c for *_r, c in found) / len(found) if found else 0.0,
262
+ "unlinked": len(names) - len({k for k in names if k in keys}),
263
+ "blurry": blurry + blurry_sel + oracle.unpaired,
264
+ "unrendered": oracle.unrendered,
265
+ "seconds": secs,
266
+ }
267
+
268
+
269
+ def main() -> int:
270
+ ap = argparse.ArgumentParser(description=__doc__)
271
+ ap.add_argument("repos", nargs="+", type=Path)
272
+ ap.add_argument("--heimdall", default="heimdall")
273
+ args = ap.parse_args()
274
+
275
+ rows = [run(r, args.heimdall) for r in args.repos]
276
+ cols = f"{'files':>5} {'docs':>5} {'refs':>5} {'byname':>7} {'sel':>5} {'bylabel':>8} {'prec':>6} {'index':>7}"
277
+ print(f"\n{'repo':26} {cols}")
278
+ for r in rows:
279
+ print(
280
+ f"{r['repo'][:26]:26} {r['files']:5} {r['docs']:5} {r['refs']:5} {r['recall']:6.1%}"
281
+ f" {r['sel']:6} {r['recall_sel']:7.1%} {r['precision']:5.1%} {r['seconds']:6.2f}s"
282
+ )
283
+ tot = {k: sum(r[k] for r in rows) for k in ("files", "docs", "refs", "hit", "sel", "hit_sel", "edges", "endorsed")}
284
+ print(
285
+ f"{'total':26} {tot['files']:5} {tot['docs']:5} {tot['refs']:5} {tot['hit'] / tot['refs']:6.1%}"
286
+ f" {tot['sel']:6} {tot['hit_sel'] / tot['sel']:7.1%} {tot['endorsed'] / tot['edges']:5.1%}"
287
+ )
288
+ print(f"\nreferences with no edge at all: {sum(r['unlinked'] for r in rows)}")
289
+ print(f"references the oracle cannot adjudicate (two files answer to the name): {sum(r['blurry'] for r in rows)}")
290
+ print(f"documents skipped as unrendered Helm templates: {sum(r['unrendered'] for r in rows)}")
291
+ return 0
292
+
293
+
294
+ if __name__ == "__main__":
295
+ sys.exit(main())
@@ -9,5 +9,5 @@ inheritance) and serves it to agents over MCP.
9
9
  from .graph import Graph
10
10
  from .index import build
11
11
 
12
- __version__ = "0.2.0"
12
+ __version__ = "0.3.1"
13
13
  __all__ = ["Graph", "__version__", "build"]
@@ -181,7 +181,11 @@ def main(argv: list[str] | None = None) -> int:
181
181
  serve(a.project)
182
182
  return 0
183
183
 
184
- g = Graph.open(registry.resolve(a.project), readonly=True)
184
+ try:
185
+ db = registry.resolve(a.project)
186
+ except LookupError as exc:
187
+ raise SystemExit(str(exc)) from None
188
+ g = Graph.open(db, readonly=True)
185
189
  try:
186
190
  match a.cmd:
187
191
  case "search":
@@ -42,8 +42,9 @@ SKIP_DIRS = {
42
42
  "vendor",
43
43
  "coverage-report",
44
44
  }
45
- # package roots and index files that do not contribute a module name segment
46
- _INDEX_STEMS = {"__init__", "index", "mod", "lib", "main"}
45
+ # package roots and index files that do not contribute a module name segment.
46
+ # `kustomization` is one: it is the index file of a directory of manifests.
47
+ _INDEX_STEMS = {"__init__", "index", "mod", "lib", "main", "kustomization"}
47
48
  _SRC_ROOTS = {"src", "lib", "app", "pkg", "internal", "source"}
48
49
  # receivers that name the enclosing class rather than another object
49
50
  SELF_NAMES = {"self", "this", "cls", "me", "Self"}
@@ -143,7 +144,7 @@ def _module_keys(rel: str) -> list[str]:
143
144
  return keys
144
145
 
145
146
 
146
- _IMPORT_EXTS = (".js", ".ts", ".jsx", ".tsx", ".py", ".h", ".hpp", ".mjs", ".cjs")
147
+ _IMPORT_EXTS = (".js", ".ts", ".jsx", ".tsx", ".py", ".h", ".hpp", ".mjs", ".cjs", ".yaml", ".yml")
147
148
 
148
149
 
149
150
  def _strip_ext(mod: str) -> str:
@@ -309,19 +310,38 @@ def build(root: Path, db_path: Path, jobs: int = 0, log=lambda m: None) -> dict:
309
310
  # sibling -- never the same-named one inside the child module it calls. So for HCL the
310
311
  # ranker's notion of "imported" is the siblings, replacing the `source` edges above;
311
312
  # those stay as edges, and their alias still points module arguments at the child.
312
- tf_dirs: dict[str, set[str]] = {}
313
+ #
314
+ # A manifest tree scopes the same way and for the same reason: a name written in one
315
+ # file means the object of that name beside it. What kustomize adds is a way to say
316
+ # which *other* directories are in scope, and it says it once per directory rather
317
+ # than once per file -- so a `resources: [../../base]` puts the base in scope for
318
+ # every manifest sitting next to that kustomization.yaml, not just for itself.
319
+ by_dir: dict[tuple[str, str], set[str]] = {}
313
320
  for fp in parsed:
314
- if fp.lang == "hcl":
315
- tf_dirs.setdefault(str(Path(fp.path).parent), set()).add(fp.path)
321
+ if fp.lang in ("hcl", "yaml"):
322
+ by_dir.setdefault((fp.lang, str(Path(fp.path).parent)), set()).add(fp.path)
323
+ paths = {fp.path for fp in parsed}
324
+ kustomized: dict[str, set[str]] = {}
325
+ for fp in parsed:
326
+ if fp.lang == "yaml" and Path(fp.path).stem == "kustomization":
327
+ here = kustomized.setdefault(str(Path(fp.path).parent), set())
328
+ for mod, _line, _scope in fp.imports:
329
+ target = _relative_target(fp.path, mod)
330
+ if target is not None: # a directory of manifests, or one manifest
331
+ here |= by_dir.get(("yaml", target), set())
332
+ here |= {p for p in (f"{target}.yaml", f"{target}.yml") if p in paths}
316
333
  for fp in parsed:
334
+ d = str(Path(fp.path).parent)
317
335
  if fp.lang == "hcl":
318
- imported[fp.path] = tf_dirs[str(Path(fp.path).parent)] - {fp.path}
336
+ imported[fp.path] = by_dir[("hcl", d)] - {fp.path}
319
337
  # and a called module is a whole directory, not the one file the generic
320
338
  # alias landed on: `region = ...` means the `variable "region"` anywhere in it
321
339
  for mod, _line, _scope in fp.imports:
322
340
  target = _relative_target(fp.path, mod)
323
341
  if target is not None:
324
342
  aliases[fp.path][target.rsplit("/", 1)[-1]] = target
343
+ elif fp.lang == "yaml":
344
+ imported[fp.path] = (by_dir[("yaml", d)] | kustomized.get(d, set())) - {fp.path}
325
345
 
326
346
  # --- name index for call/heritage resolution
327
347
  by_name: dict[str, list[tuple[int, str, str, str, str]]] = {}
@@ -386,6 +406,32 @@ def build(root: Path, db_path: Path, jobs: int = 0, log=lambda m: None) -> dict:
386
406
  for dst, _path, _kind, conf in ranked[:5]:
387
407
  edges.append((src, dst, "CALLS", conf, line)) # a self-edge is recursion
388
408
 
409
+ # --- kubernetes label selectors: the one reference here that is not written as a
410
+ # name. A Service says which labels it wants, a Deployment says which labels its
411
+ # pods carry, and the edge exists when the first set is inside the second -- in
412
+ # the same namespace, so a prod Service cannot end up wired to staging pods.
413
+ # Scope decides between matches exactly as it does for a name: a repo that keeps
414
+ # three copies of the same app -- plain manifests, a kustomize base, a rendered
415
+ # release -- has every copy answering to `app: adservice`, and the one in scope is
416
+ # the one that was meant.
417
+ # ponytail: selectors x workloads, which on a manifest repo is thousands of set
418
+ # comparisons. An index keyed by one label would be the fix if that stops being true.
419
+ offers = [(ids[q], fp.path, ns, lab) for fp in parsed for q, ns, lab in fp.labels if q in ids]
420
+ for fp in parsed:
421
+ here = imported.get(fp.path, ())
422
+ for qname, ns, want, line in fp.selectors:
423
+ src = ids.get(qname)
424
+ if src is None:
425
+ continue
426
+ hits = [(dst, path) for dst, path, dns, lab in offers if dns == ns and want.items() <= lab.items()]
427
+ hits = [h for h in hits if h[0] != src]
428
+ near = [h for h in hits if h[1] == fp.path] or [h for h in hits if h[1] in here]
429
+ conf = 0.95 if any(h[1] == fp.path for h in near) else 0.9 if near else 0.7
430
+ if not near and len(hits) != 1:
431
+ continue # several copies and nothing to choose between them: say nothing
432
+ for dst, _path in (near or hits)[:5]:
433
+ edges.append((src, dst, "CALLS", conf, line))
434
+
389
435
  g.add_edges(edges)
390
436
  log("pass 4/4 building search index")
391
437
  g.reindex_fts()
@@ -6,9 +6,9 @@ Language support is a data table, not code: node-type -> symbol kind. Tree-sitte
6
6
  grammars name their nodes consistently enough that one generic walker covers every
7
7
  language here. Adding a language means adding rows, not a module.
8
8
 
9
- HCL is the one exception, and it is a real one rather than a shortcut: Terraform has no
10
- definitions and no calls, so it gets its own reader further down instead of bending the
11
- walker around it.
9
+ HCL and YAML are the two exceptions, and they are real ones rather than shortcuts:
10
+ Terraform has no definitions and no calls, and a Kubernetes manifest has neither either.
11
+ Both get their own reader further down instead of bending the walker around them.
12
12
  """
13
13
 
14
14
  from __future__ import annotations
@@ -50,6 +50,8 @@ EXT_LANG = {
50
50
  ".sh": "bash",
51
51
  ".tf": "hcl",
52
52
  ".hcl": "hcl",
53
+ ".yaml": "yaml",
54
+ ".yml": "yaml",
53
55
  }
54
56
 
55
57
  _JS_DEFS = {
@@ -252,6 +254,11 @@ class FileParse:
252
254
  renames: list[tuple[str, str, int]] = field(default_factory=list)
253
255
  # (class_qname, base_name)
254
256
  heritage: list[tuple[str, str]] = field(default_factory=list)
257
+ # kubernetes selects by shape, not by name: a Service names no Deployment, it names
258
+ # the labels one puts on its pods. (qname, namespace, labels offered) and
259
+ # (qname, namespace, labels required, line) -- matched against each other in index.py.
260
+ labels: list[tuple[str, str, dict[str, str]]] = field(default_factory=list)
261
+ selectors: list[tuple[str, str, dict[str, str], int]] = field(default_factory=list)
255
262
  error: str | None = None
256
263
 
257
264
 
@@ -558,6 +565,179 @@ def _parse_hcl(out: FileParse, root, src: bytes) -> FileParse:
558
565
  return out
559
566
 
560
567
 
568
+ # ---------------------------------------------------------------- kubernetes (yaml)
569
+ #
570
+ # The second reader beside the walker, and for the same reason as the first: a manifest
571
+ # declares nothing and calls nothing either. What it has is a document that names itself
572
+ # the way `kubectl` addresses it -- `kind` plus `metadata.name` -- and fields elsewhere
573
+ # holding the names of other documents. The *field* is what says which kind a name refers
574
+ # to: the string under `configMapRef.name` can only be a ConfigMap. So the rules are a
575
+ # table of field paths, and yaml that is not a Kubernetes object produces nothing at all.
576
+
577
+ # field path suffix -> the kind the value names. "*" means the kind is not implied by the
578
+ # field but written beside it, in a sibling `kind`.
579
+ K8S_REFS = {
580
+ "serviceAccountName": "ServiceAccount",
581
+ "secretName": "Secret",
582
+ "claimName": "PersistentVolumeClaim",
583
+ "storageClassName": "StorageClass",
584
+ "ingressClassName": "IngressClass",
585
+ "priorityClassName": "PriorityClass",
586
+ "runtimeClassName": "RuntimeClass",
587
+ "volumeName": "PersistentVolume",
588
+ "serviceName": "Service",
589
+ "configMap.name": "ConfigMap",
590
+ "configMapRef.name": "ConfigMap",
591
+ "configMapKeyRef.name": "ConfigMap",
592
+ "secret.name": "Secret",
593
+ "secretRef.name": "Secret",
594
+ "secretKeyRef.name": "Secret",
595
+ "imagePullSecrets.name": "Secret",
596
+ "backend.service.name": "Service",
597
+ "roleRef.name": "*",
598
+ "subjects.name": "*",
599
+ "scaleTargetRef.name": "*",
600
+ "targetRef.name": "*",
601
+ "ownerReferences.name": "*",
602
+ }
603
+ # the paths a label selector is written at. A workload's own `spec.selector` selects its
604
+ # own pods, which is why an object that offers pod labels never gets a selector edge.
605
+ K8S_SELECTORS = {
606
+ ("spec", "selector"),
607
+ ("spec", "selector", "matchLabels"),
608
+ ("spec", "podSelector", "matchLabels"),
609
+ }
610
+ # kustomize: the only import a manifest tree has
611
+ K8S_INCLUDES = {"resources", "bases", "components", "patchesStrategicMerge", "patches", "crds"}
612
+
613
+
614
+ def _yaml_text(node) -> str:
615
+ return _text(node).strip().strip("\"'")
616
+
617
+
618
+ def _k8s_name(v: str) -> bool:
619
+ """A name Kubernetes would accept. This is also what rules out an unrendered chart:
620
+ `{{ .Release.Name }}-api` is not a name until something renders it."""
621
+ return 0 < len(v) < 254 and v[0].isalnum() and all(c.isalnum() or c in "._-:@" for c in v)
622
+
623
+
624
+ def _yaml_leaves(node, path: tuple[str, ...], out: list, depth: int = 0) -> None:
625
+ """(path, value, line) for every scalar in the tree.
626
+
627
+ The path stays a tuple rather than a dotted string because label keys are routinely
628
+ `app.kubernetes.io/name`, and a dotted path could not be split back apart. List
629
+ indices are part of it: they are what tells two `configMapRef`s in one pod apart, and
630
+ what makes the sibling `kind` of a `roleRef` findable.
631
+ """
632
+ # ponytail: a stack guard, not a semantic limit. A CronJob's pod spec is ~15 keys
633
+ # deep and every key costs four frames here; nothing real comes near this.
634
+ if depth > 400:
635
+ return
636
+ if node.type in ("block_mapping_pair", "flow_pair"):
637
+ k, v = node.child_by_field_name("key"), node.child_by_field_name("value")
638
+ if k is not None and v is not None:
639
+ _yaml_leaves(v, (*path, _yaml_text(k)), out, depth + 1)
640
+ return
641
+ if node.type in ("block_sequence", "flow_sequence"):
642
+ i = 0
643
+ for c in node.children:
644
+ if c.type in ("block_sequence_item", "block_node", "flow_node"):
645
+ _yaml_leaves(c, (*path, str(i)), out, depth + 1)
646
+ i += 1
647
+ return
648
+ if node.type == "flow_node" and not any(c.type in ("flow_mapping", "flow_sequence") for c in node.children):
649
+ out.append((path, _yaml_text(node), node.start_point[0] + 1))
650
+ return
651
+ for c in node.children:
652
+ _yaml_leaves(c, path, out, depth + 1)
653
+
654
+
655
+ def _k8s_ref(path: tuple[str, ...]) -> str | None:
656
+ """The kind this field's value names, if the field is one that names something."""
657
+ bare = [s for s in path if not s.isdigit()]
658
+ for n in (3, 2, 1):
659
+ if len(bare) >= n:
660
+ kind = K8S_REFS.get(".".join(bare[-n:]))
661
+ if kind:
662
+ return kind
663
+ return None
664
+
665
+
666
+ def _yaml_documents(src: bytes) -> list[tuple[int, bytes]]:
667
+ """(first line number, bytes) per document, split on YAML's own separator.
668
+
669
+ Before the parser rather than after it, because a rendered manifest is one file of
670
+ sixty documents and a single one the grammar cannot follow takes every document after
671
+ it down with it -- the remains end up loose at the top of the tree with no `---` left
672
+ to tell them apart. Split first and the damage stops at the document that caused it.
673
+ """
674
+ docs: list[tuple[int, bytes]] = []
675
+ start, buf = 1, []
676
+ for i, line in enumerate(src.split(b"\n"), 1):
677
+ if line.rstrip() == b"---" or line.startswith(b"--- "):
678
+ docs.append((start, b"\n".join(buf)))
679
+ start, buf = i + 1, []
680
+ else:
681
+ buf.append(line)
682
+ docs.append((start, b"\n".join(buf)))
683
+ return [(n, b) for n, b in docs if b.strip()]
684
+
685
+
686
+ def _parse_yaml(out: FileParse, parser, src: bytes) -> FileParse:
687
+ for first, chunk in _yaml_documents(src):
688
+ root = parser.parse(chunk).root_node
689
+ found: list[tuple[tuple[str, ...], str, int]] = []
690
+ _yaml_leaves(root, (), found)
691
+ leaves = [(p, v, first + ln - 1) for p, v, ln in found]
692
+ vals = {p: v for p, v, _line in leaves}
693
+
694
+ # kustomize `resources` names the files and directories this one composes. They
695
+ # are relative to this file, always -- said with a leading `./` so the resolver
696
+ # cannot mistake one for the name of a module somewhere else in the repo.
697
+ for path, value, line in leaves:
698
+ if path[:1] and path[0] in K8S_INCLUDES and (path[-1].isdigit() or path[-1] == "path"):
699
+ if "://" not in value and not value.startswith(("http", "git@")):
700
+ out.imports.append((value if value.startswith(".") else f"./{value}", line, None))
701
+
702
+ kind, name = vals.get(("kind",)), vals.get(("metadata", "name"))
703
+ if not (("apiVersion",) in vals and kind and name and kind.isalnum() and _k8s_name(name)):
704
+ continue # any other yaml, or a chart nobody has rendered
705
+ ns = vals.get(("metadata", "namespace"), "")
706
+ addr = f"{kind}/{name}"
707
+ sym = Symbol(
708
+ kind="Class",
709
+ name=addr,
710
+ qname=f"{out.path}:{addr}",
711
+ line=first + root.start_point[0],
712
+ end_line=first + root.end_point[0],
713
+ parent=None,
714
+ signature=f"{addr} -n {ns}" if ns else addr,
715
+ )
716
+ out.symbols.append(sym)
717
+
718
+ for path, value, line in leaves:
719
+ target = _k8s_ref(path)
720
+ if target is None or not _k8s_name(value):
721
+ continue
722
+ if target == "*":
723
+ target = vals.get((*path[:-1], "kind"), "")
724
+ if target.isalnum():
725
+ out.calls.append((sym.qname, f"{target}/{value}", line, None, True))
726
+
727
+ # a pod template is a pod template wherever the workload keeps it: a Deployment
728
+ # holds one at spec.template, a CronJob two levels further down
729
+ if kind == "Pod":
730
+ offered = {p[-1]: v for p, v in vals.items() if p[:-1] == ("metadata", "labels")}
731
+ else:
732
+ offered = {p[-1]: v for p, v in vals.items() if p[-4:-1] == ("template", "metadata", "labels")}
733
+ selects = [(p[-1], v, line) for p, v, line in leaves if p[:-1] in K8S_SELECTORS]
734
+ if offered:
735
+ out.labels.append((sym.qname, ns, offered))
736
+ elif selects:
737
+ out.selectors.append((sym.qname, ns, {k: v for k, v, _line in selects}, min(ln for _k, _v, ln in selects)))
738
+ return out
739
+
740
+
561
741
  # ---------------------------------------------------------------- the walker
562
742
 
563
743
 
@@ -568,7 +748,11 @@ def parse_source(path: str, lang: str, src: bytes) -> FileParse:
568
748
  imports = IMPORT_NODES.get(lang, set())
569
749
  consts = CONST_NODES.get(lang, set())
570
750
  try:
571
- tree = get_parser(lang).parse(src)
751
+ parser = get_parser(lang)
752
+ # yaml is parsed one document at a time, so it does its own
753
+ if lang == "yaml":
754
+ return _parse_yaml(out, parser, src)
755
+ tree = parser.parse(src)
572
756
  except Exception as exc: # unparsable file: record and keep indexing the repo
573
757
  out.error = f"{type(exc).__name__}: {exc}"
574
758
  return out
@@ -31,7 +31,12 @@ def register(name: str, db: Path) -> None:
31
31
 
32
32
 
33
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."""
34
+ """A project name, an explicit db path, or the repo in the current directory.
35
+
36
+ Failure is a LookupError, never SystemExit: this also runs inside the MCP server,
37
+ where a BaseException escapes the tool handler and kills the process mid-request --
38
+ the client then hangs on a reply that will never come.
39
+ """
35
40
  if project:
36
41
  p = Path(project)
37
42
  if p.suffix == ".db" and p.exists():
@@ -42,10 +47,10 @@ def resolve(project: str | None, cwd: Path | None = None) -> Path:
42
47
  matches = [v for k, v in entries.items() if project in k]
43
48
  if len(matches) == 1:
44
49
  return Path(matches[0])
45
- raise SystemExit(f"unknown project {project!r}; known: {', '.join(sorted(load())) or '(none)'}")
50
+ raise LookupError(f"unknown project {project!r}; known: {', '.join(sorted(load())) or '(none)'}")
46
51
  here = (cwd or Path.cwd()).resolve()
47
52
  for d in (here, *here.parents):
48
53
  db = d / ".heimdall" / "graph.db"
49
54
  if db.exists():
50
55
  return db
51
- raise SystemExit("no index found here -- run `heimdall index` first, or pass --project")
56
+ raise LookupError("no index found here -- run `heimdall index` first, or pass --project")
@@ -1,7 +1,7 @@
1
1
  [project]
2
2
  # `heimdall` on PyPI is an unrelated screenshot library; the command is still `heimdall`
3
3
  name = "heimdall-code"
4
- version = "0.2.0"
4
+ version = "0.3.1"
5
5
  description = "A code knowledge graph that sees everything -- indexes any repository into a SQLite property graph and serves it to AI agents over MCP."
6
6
  readme = "README.md"
7
7
  requires-python = ">=3.12"
@@ -1,6 +1,6 @@
1
1
  ---
2
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 19 languages, Terraform included.
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 20 languages, Terraform and Kubernetes manifests included.
4
4
  ---
5
5
 
6
6
  # Heimdall
@@ -171,6 +171,83 @@ output "subnet_id" {
171
171
  value = var.region
172
172
  }
173
173
  """,
174
+ # kubernetes: a document is addressed `Kind/name`, and which kind a name refers to is
175
+ # decided by the field holding it
176
+ "k8s/base/deployment.yaml": """
177
+ apiVersion: apps/v1
178
+ kind: Deployment
179
+ metadata:
180
+ name: api
181
+ spec:
182
+ selector:
183
+ matchLabels:
184
+ app: api
185
+ template:
186
+ metadata:
187
+ labels:
188
+ app: api
189
+ spec:
190
+ serviceAccountName: api-sa
191
+ containers:
192
+ - name: web
193
+ envFrom:
194
+ - configMapRef:
195
+ name: app-config
196
+ """,
197
+ # two documents in one file, and a same-labelled workload in another namespace that
198
+ # the Service below must *not* be wired to
199
+ "k8s/base/extras.yaml": """
200
+ apiVersion: v1
201
+ kind: ConfigMap
202
+ metadata:
203
+ name: app-config
204
+ data:
205
+ LEVEL: debug
206
+ ---
207
+ apiVersion: v1
208
+ kind: ServiceAccount
209
+ metadata:
210
+ name: api-sa
211
+ ---
212
+ apiVersion: apps/v1
213
+ kind: Deployment
214
+ metadata:
215
+ name: api-staging
216
+ namespace: staging
217
+ spec:
218
+ template:
219
+ metadata:
220
+ labels:
221
+ app: api
222
+ spec:
223
+ containers:
224
+ - name: web
225
+ """,
226
+ # a Service names no Deployment: it names the labels one puts on its pods
227
+ "k8s/base/service.yaml": """
228
+ apiVersion: v1
229
+ kind: Service
230
+ metadata:
231
+ name: api
232
+ spec:
233
+ selector:
234
+ app: api
235
+ """,
236
+ "k8s/base/kustomization.yaml": "resources:\n - deployment.yaml\n - extras.yaml\n - service.yaml\n",
237
+ # ... and an overlay reaches the base only because its kustomization says so
238
+ "k8s/overlays/prod/kustomization.yaml": "resources:\n - ../../base\n",
239
+ "k8s/overlays/prod/hpa.yaml": """
240
+ apiVersion: autoscaling/v2
241
+ kind: HorizontalPodAutoscaler
242
+ metadata:
243
+ name: api
244
+ spec:
245
+ scaleTargetRef:
246
+ kind: Deployment
247
+ name: api
248
+ """,
249
+ # yaml that is not a manifest is not a symbol
250
+ "ci.yml": "jobs:\n build:\n steps:\n - run: make\n",
174
251
  }
175
252
 
176
253
 
@@ -280,6 +357,25 @@ def test_end_to_end() -> None:
280
357
  callers = {c["qname"] for c in g.context("infra/modules/vpc/main.tf:var.region")["callers"]}
281
358
  assert callers == {"infra/main.tf:module.vpc", "infra/modules/vpc/main.tf:output.subnet_id"}, callers
282
359
 
360
+ # kubernetes: `kind` + `metadata.name` is the address, and the field a name sits
361
+ # in is what says which kind it refers to
362
+ assert g.resolve("k8s/base/deployment.yaml:Deployment/api"), "document address"
363
+ refs = {c["qname"] for c in g.context("k8s/base/deployment.yaml:Deployment/api")["calls"]}
364
+ assert refs == {"k8s/base/extras.yaml:ConfigMap/app-config", "k8s/base/extras.yaml:ServiceAccount/api-sa"}, refs
365
+
366
+ # a Service selects labels, not names -- and only in its own namespace, so the
367
+ # identically labelled staging Deployment is not one of its answers
368
+ selected = {c["qname"] for c in g.context("k8s/base/service.yaml:Service/api")["calls"]}
369
+ assert selected == {"k8s/base/deployment.yaml:Deployment/api"}, selected
370
+
371
+ # an overlay reaches the base because its kustomization.yaml says it does
372
+ hpa = g.context("k8s/overlays/prod/hpa.yaml:HorizontalPodAutoscaler/api")["calls"]
373
+ assert [c["qname"] for c in hpa] == ["k8s/base/deployment.yaml:Deployment/api"], hpa
374
+ assert hpa[0]["confidence"] == 0.8
375
+
376
+ # yaml that is not a manifest contributes a file and nothing else
377
+ assert g.query("SELECT count(*) AS n FROM node WHERE path = 'ci.yml'")[0]["n"] == 1
378
+
283
379
  # dead code is visible as "no callers"
284
380
  assert g.context("src/loader.py:unused")["callers"] == []
285
381
 
@@ -318,6 +414,7 @@ SNIPPETS = {
318
414
  "dart": ("a.dart", "import 'x.dart';\nclass A extends B { void m(){ foo(); } }\n"),
319
415
  "bash": ("a.sh", "f() { g; }\n"),
320
416
  "hcl": ("main.tf", 'variable "r" {}\nresource "aws_s3_bucket" "b" {\n bucket = var.r\n}\n'),
417
+ "yaml": ("p.yaml", "apiVersion: v1\nkind: Pod\nmetadata:\n name: p\nspec:\n serviceAccountName: sa\n"),
321
418
  }
322
419
 
323
420
 
@@ -354,6 +451,20 @@ def test_install_writes_agent_config() -> None:
354
451
  assert r.returncode == 0, r.stdout + r.stderr # the assertion that failed, not just "exit 1"
355
452
 
356
453
 
454
+ def test_resolve_failures_are_ordinary_exceptions() -> None:
455
+ """SystemExit here killed the MCP server mid-request; the client hung, then reconnected."""
456
+ from heimdall import registry
457
+
458
+ with tempfile.TemporaryDirectory() as d:
459
+ for bad in (lambda: registry.resolve(None, Path(d)), lambda: registry.resolve("nope::missing", Path(d))):
460
+ try:
461
+ bad()
462
+ except Exception as exc: # LookupError is an Exception; SystemExit is not
463
+ assert isinstance(exc, LookupError), exc
464
+ else:
465
+ raise AssertionError("expected a LookupError")
466
+
467
+
357
468
  def test_every_declared_language_extracts() -> None:
358
469
  from heimdall.parse import EXT_LANG, parse_source
359
470
 
@@ -369,5 +480,6 @@ if __name__ == "__main__":
369
480
  test_end_to_end()
370
481
  test_submodules_are_indexed()
371
482
  test_install_writes_agent_config()
483
+ test_resolve_failures_are_ordinary_exceptions()
372
484
  test_every_declared_language_extracts()
373
485
  print("ok")
@@ -204,7 +204,7 @@ wheels = [
204
204
 
205
205
  [[package]]
206
206
  name = "heimdall-code"
207
- version = "0.2.0"
207
+ version = "0.3.1"
208
208
  source = { editable = "." }
209
209
  dependencies = [
210
210
  { name = "mcp" },
File without changes
File without changes
File without changes
File without changes