heimdall-code 0.2.0__tar.gz → 0.3.0__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.
- {heimdall_code-0.2.0 → heimdall_code-0.3.0}/.claude-plugin/plugin.json +1 -1
- {heimdall_code-0.2.0 → heimdall_code-0.3.0}/CHANGELOG.md +31 -0
- {heimdall_code-0.2.0 → heimdall_code-0.3.0}/CONTRIBUTING.md +16 -10
- {heimdall_code-0.2.0 → heimdall_code-0.3.0}/PKG-INFO +42 -8
- {heimdall_code-0.2.0 → heimdall_code-0.3.0}/README.md +41 -7
- {heimdall_code-0.2.0 → heimdall_code-0.3.0}/bench/README.md +49 -0
- {heimdall_code-0.2.0 → heimdall_code-0.3.0}/bench/RESULTS.md +5 -5
- heimdall_code-0.3.0/bench/kubernetes.py +295 -0
- {heimdall_code-0.2.0 → heimdall_code-0.3.0}/heimdall/__init__.py +1 -1
- {heimdall_code-0.2.0 → heimdall_code-0.3.0}/heimdall/index.py +53 -7
- {heimdall_code-0.2.0 → heimdall_code-0.3.0}/heimdall/parse.py +188 -4
- {heimdall_code-0.2.0 → heimdall_code-0.3.0}/pyproject.toml +1 -1
- {heimdall_code-0.2.0 → heimdall_code-0.3.0}/skills/heimdall/SKILL.md +1 -1
- {heimdall_code-0.2.0 → heimdall_code-0.3.0}/tests/test_heimdall.py +97 -0
- {heimdall_code-0.2.0 → heimdall_code-0.3.0}/uv.lock +1 -1
- {heimdall_code-0.2.0 → heimdall_code-0.3.0}/.claude-plugin/marketplace.json +0 -0
- {heimdall_code-0.2.0 → heimdall_code-0.3.0}/.github/workflows/release.yml +0 -0
- {heimdall_code-0.2.0 → heimdall_code-0.3.0}/.gitignore +0 -0
- {heimdall_code-0.2.0 → heimdall_code-0.3.0}/.mcp.json +0 -0
- {heimdall_code-0.2.0 → heimdall_code-0.3.0}/CLAUDE.md +0 -0
- {heimdall_code-0.2.0 → heimdall_code-0.3.0}/LICENSE +0 -0
- {heimdall_code-0.2.0 → heimdall_code-0.3.0}/bench/.gitignore +0 -0
- {heimdall_code-0.2.0 → heimdall_code-0.3.0}/bench/QUESTIONS.md +0 -0
- {heimdall_code-0.2.0 → heimdall_code-0.3.0}/bench/compare.py +0 -0
- {heimdall_code-0.2.0 → heimdall_code-0.3.0}/bench/dump_lbug.mjs +0 -0
- {heimdall_code-0.2.0 → heimdall_code-0.3.0}/bench/questions.py +0 -0
- {heimdall_code-0.2.0 → heimdall_code-0.3.0}/bench/terraform.py +0 -0
- {heimdall_code-0.2.0 → heimdall_code-0.3.0}/heimdall/__main__.py +0 -0
- {heimdall_code-0.2.0 → heimdall_code-0.3.0}/heimdall/cli.py +0 -0
- {heimdall_code-0.2.0 → heimdall_code-0.3.0}/heimdall/graph.py +0 -0
- {heimdall_code-0.2.0 → heimdall_code-0.3.0}/heimdall/install.py +0 -0
- {heimdall_code-0.2.0 → heimdall_code-0.3.0}/heimdall/mcp_server.py +0 -0
- {heimdall_code-0.2.0 → heimdall_code-0.3.0}/heimdall/registry.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "heimdall",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
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,37 @@
|
|
|
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.0 — 2026-08-01
|
|
7
|
+
|
|
8
|
+
### Added
|
|
9
|
+
|
|
10
|
+
- **Kubernetes manifests**, as the 20th language and the second one the generic walker
|
|
11
|
+
could not reach. A document with `apiVersion`, `kind` and `metadata.name` is indexed
|
|
12
|
+
under the address `kubectl` uses for it — `Deployment/api` — and every field holding
|
|
13
|
+
the name of another document becomes a `CALLS` edge. The field decides the kind: a name
|
|
14
|
+
under `configMapRef.name` can only be a ConfigMap, and where the field does not say
|
|
15
|
+
(`roleRef`, `scaleTargetRef`) the sibling `kind` does. So `impact ConfigMap/app-config`
|
|
16
|
+
now answers with the workloads that would restart.
|
|
17
|
+
- **Label selectors are edges too**, which is the one Kubernetes reference not written as
|
|
18
|
+
a name: a Service's selector is matched against the labels a workload puts on its pods,
|
|
19
|
+
within one namespace, so a prod Service cannot end up wired to staging pods. Scope
|
|
20
|
+
settles which copy was meant — a directory of manifests is a scope the way a Terraform
|
|
21
|
+
module is, and `kustomization.yaml`'s `resources` say which other directories join it.
|
|
22
|
+
- `bench/kubernetes.py`, graded by an oracle that re-reads the manifests by indentation
|
|
23
|
+
and shares no code with the tree-sitter reader. Across four public repos — 1,988 files,
|
|
24
|
+
2,748 documents — every reference by name and by label resolves to the right document
|
|
25
|
+
and none of the edges is wrong. The one reference with no edge is `##CRBNAME##`, a sed
|
|
26
|
+
placeholder in an argo-cd test fixture, which is not a name.
|
|
27
|
+
- Yaml that is not a manifest is a file in the graph and nothing more. An unrendered Helm
|
|
28
|
+
chart is skipped rather than guessed at: `{{ .Release.Name }}-api` is not a name.
|
|
29
|
+
|
|
30
|
+
### Fixed
|
|
31
|
+
|
|
32
|
+
- A yaml file is now parsed one document at a time, split on YAML's own `---` before the
|
|
33
|
+
grammar sees it. A single document the grammar could not follow used to take every
|
|
34
|
+
document after it down with it — in argo-cd's 1.9 MB rendered `install.yaml` that was
|
|
35
|
+
82 of 130 objects, left out of the graph with nothing to say they were missing.
|
|
36
|
+
|
|
6
37
|
## 0.2.0 — 2026-08-01
|
|
7
38
|
|
|
8
39
|
### 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
|
|
39
|
-
does not either:
|
|
40
|
-
express
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
|
60
|
-
|
|
61
|
-
|
|
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
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: heimdall-code
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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 | **
|
|
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
|
|
249
|
-
asserts it still yields symbols and call edges, so a grammar rename fails the
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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 | **
|
|
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
|
|
229
|
-
asserts it still yields symbols and call edges, so a grammar rename fails the
|
|
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 |
|
|
48
|
-
| GitNexus (LadybugDB Cypher, node subprocess) | 1,904 | 1,904 |
|
|
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.
|
|
59
|
-
| re-index, nothing changed | 2.
|
|
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 |
|
|
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())
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
315
|
-
|
|
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] =
|
|
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
|
|
10
|
-
definitions and no calls,
|
|
11
|
-
walker around
|
|
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
|
-
|
|
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
|
|
@@ -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.
|
|
4
|
+
version = "0.3.0"
|
|
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
|
|
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
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|