Skip to content

Commit 5370777

Browse files
authored
fix(config): name the resolved config, and stop plugin lists shadowing defaults (#166)
A config decides which extractors run and which paths are ignored, so loading the wrong one does not fail — it analyses something other than what was asked for. Two behaviours combined to make that silent: lookup falls back to the binary's own directory, and a list-valued key replaces the built-in list rather than extending it. An eleven-extractor config beside a `go build` output therefore disabled Rust for every repository that binary was pointed at, from any directory without a config of its own: a 780-file Rust repo reported 0 facts, no error, no mention of Rust anywhere in the log. - bootstrap.ResolveConfig returns the resolved path alongside the config, and NewEngine prints it on every command. check/coverage build their note from cfg.SourcePath too — they reported the config they looked FOR, which under the fallback was not the one in force. - The executable-adjacent fallback is restricted to a binary that is not on PATH (an unpacked bundle rather than an installed one), and discloses itself. - config.Load records whether the file listed `extractors:` itself; when it did, the engine detects the disabled extractors and warns for any that match the repository, recording them as shadowed_extractors in the receipt. - mcp-arch.yaml and examples/*.yaml no longer declare extractors/explainers/ renderers. Beyond the reported instance: every example pinned four of the ten explainers, so adopters lost the other six, and full.yaml claimed to enable all languages while omitting grpc, openapi and python. `extractors:` still replaces rather than merges — it is the only way to disable an extractor, and trading a loud omission for a silent inability to turn things off is the worse bargain. Tests pin the resolution rules, the PATH restriction, the shadow report, and the absence of plugin lists from every shipped config.
1 parent d7709cd commit 5370777

23 files changed

Lines changed: 835 additions & 153 deletions

ARCHITECTURE.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -825,7 +825,13 @@ output:
825825
max_context_tokens: 16000
826826
```
827827
828-
The bundled [`mcp-arch.yaml`](mcp-arch.yaml) ships a much fuller `ignore` list (Android/Gradle, Xcode/SPM, Rails, CI, Docker, env files, …); see it and the per-language configs under [`examples/`](examples/) for ready-made starting points.
828+
The bundled [`mcp-arch.yaml`](mcp-arch.yaml) ships a much fuller `ignore` list (Android/Gradle, Xcode/SPM, Rails, CI, Docker, env files, …); see it and the per-language configs under [`examples/`](examples/) for ready-made starting points. It deliberately declares **no** `extractors:`, `explainers:` or `renderers:` — see the override rule below.
829+
830+
**Resolution, and why it is announced.** `bootstrap.ResolveConfig` looks for the named path (default `mcp-arch.yaml`) relative to the working directory, then — only when the running binary is *not* on `PATH`, i.e. an unpacked bundle rather than an installed one — beside the executable. Whatever it settles on is printed to stderr by every command (`enola: using config <path>`, or `no mcp-arch.yaml in <cwd>, using built-in defaults`), and the executable-adjacent case says so explicitly.
831+
832+
That line exists because the failure it prevents is silent. A config decides which extractors run and which paths are ignored, so loading the wrong one does not error — it analyses something other than what was asked for. Before the restriction and the announcement, a config sitting beside a `go build` output governed every repository that binary was ever pointed at, from any directory without one of its own; an eleven-extractor list written before the Rust extractor landed turned a 780-file Rust repository into `0 facts`, with no error and no mention of Rust anywhere in the log.
833+
834+
**A list-valued key REPLACES its default; it does not merge.** `yaml.Unmarshal` overwrites the slice, so `extractors:` names the complete set — a config written before an extractor existed disables it permanently, and a disabled extractor is never tried and so never appears in the log. Two things make that visible: a bundled config that names no plugin lists at all, and a warning naming any *excluded* extractor that would have detected the repository (also recorded as `shadowed_extractors` in the snapshot receipt). The semantics are unchanged on purpose — an explicit list is the only way to disable an extractor.
829835

830836
| Field | Description | Default |
831837
|-------|-------------|---------|

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,7 @@ enola check --warn-only # report everything, fail nothing
119119

120120
Not a language model, and not embeddings. enola parses your source with tree-sitter and language-specific extractors, normalizes it into a typed fact model, links it into a directed graph, and runs real graph algorithms over it — Tarjan's SCC for cycles, cycle-safe longest-path for dependency depth, mean+2σ outlier tests for the statistical findings.
121121

122-
That means the same commit yields the same answer, every time. Every snapshot carries a **receipt**: enola's version, the git ref and whether the tree was dirty, the extractors used, and a snapshot ID that's a `sha256` fingerprint of the facts rather than a random UUID. Before trusting a comparison, enola checks the two snapshots were even built the same way — a different extractor set or changed ignore rules makes a diff meaningless, and it says so instead of reporting churn as if it were your change.
122+
That means the same commit yields the same answer, every time — measured, not asserted: across 30 open-source repositories indexed three times each, all 30 produced a byte-identical snapshot ID and a byte-identical fact file, over 3.9 million facts with zero parse errors ([BENCHMARKS.md](docs/BENCHMARKS.md)). Every snapshot carries a **receipt**: enola's version, the git ref and whether the tree was dirty, the extractors used, and a snapshot ID that's a `sha256` fingerprint of the facts rather than a random UUID. Before trusting a comparison, enola checks the two snapshots were even built the same way — a different extractor set or changed ignore rules makes a diff meaningless, and it says so instead of reporting churn as if it were your change.
123123

124124
Nothing leaves your machine. It's a local binary reading local files.
125125

@@ -173,6 +173,8 @@ Framework- and platform-specific detection for each language is described in **[
173173
## Learn more
174174

175175
- **[docs/CLI.md](docs/CLI.md)** - setup, every command and flag, the exit codes, and the `--explain` report.
176+
- **[docs/BENCHMARKS.md](docs/BENCHMARKS.md)** - reproducibility, delta precision, cross-repo coverage and scale, measured on 30 public repositories - including the three defects the run found in enola itself.
177+
- **[docs/extraction/](docs/extraction/)** - per language, what specific code produces which facts, from committed fixtures - and what each extractor deliberately does not resolve.
176178
- **[ARCHITECTURE.md](ARCHITECTURE.md)** - the concept, the fact model, the pipeline, the MCP tool reference, and the value model.
177179
- **[examples/](examples/)** - ready-made per-language and multi-repo configs, plus a pre-commit hook and a CI workflow.
178180

cmd/enola/check.go

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -41,11 +41,9 @@ type target struct {
4141
func resolveTarget(arg string) target {
4242
cfgPath := "mcp-arch.yaml"
4343
repoOverride := ""
44-
note := ""
4544

4645
switch {
4746
case arg == "":
48-
note = "config " + cfgPath + " (or built-in defaults)"
4947
case isDirectory(arg):
5048
abs, err := filepath.Abs(arg)
5149
if err != nil {
@@ -54,19 +52,28 @@ func resolveTarget(arg string) target {
5452
repoOverride = abs
5553
if inner := filepath.Join(abs, "mcp-arch.yaml"); fileExists(inner) {
5654
cfgPath = inner
57-
note = "repo " + abs + " (config " + inner + ")"
58-
} else {
59-
note = "repo " + abs + " (built-in default config)"
6055
}
6156
default:
6257
cfgPath = arg
63-
note = "config " + arg
6458
}
6559

6660
eng, cfg, err := bootstrap.NewEngine(bootstrap.Options{ConfigPath: cfgPath})
6761
if err != nil {
6862
checkFatal("failed to create engine: %v", err)
6963
}
64+
65+
// The note names the config that was LOADED, not the one that was looked for.
66+
// The two differ whenever the lookup falls back — to built-in defaults, or to a
67+
// config sitting beside the binary — and a note that reports the intent as
68+
// though it were the outcome is worse than none: it is a confirmation of
69+
// something nobody checked.
70+
note := "built-in default config"
71+
if cfg.SourcePath != "" {
72+
note = "config " + cfg.SourcePath
73+
}
74+
if repoOverride != "" {
75+
note = "repo " + repoOverride + " (" + note + ")"
76+
}
7077
if repoOverride != "" {
7178
// Repos would otherwise win in RepoPaths and silently ignore the directory the
7279
// caller named.

docs/CLI.md

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,18 @@ Because your agent launches enola as a long-lived MCP server process, an upgrade
4141

4242
### Configuration (optional)
4343

44-
**enola needs no config file.** Every setting has a built-in default, so out of the box it indexes the current repo with all extractors enabled and writes to `.enola/`. A config file (`mcp-arch.yaml`) only *overrides* those defaults - it never adds capability you'd otherwise lack. When enola can't find one it simply prints `warning: …, using defaults` and carries on.
44+
**enola needs no config file.** Every setting has a built-in default, so out of the box it indexes the current repo with all extractors enabled and writes to `.enola/`. A config file (`mcp-arch.yaml`) only *overrides* those defaults - it never adds capability you'd otherwise lack.
45+
46+
Every command prints the config it resolved, on stderr, before it does anything:
47+
48+
```
49+
enola: using config /Users/you/src/api/mcp-arch.yaml
50+
enola: no mcp-arch.yaml in /Users/you/src/api, using built-in defaults
51+
```
52+
53+
It is worth reading. A config decides which extractors run and which paths are ignored, so the wrong one does not fail - it analyses something other than what you asked for. enola looks in the working directory, then (only for a binary that is *not* on your `PATH`, i.e. an unpacked bundle rather than an installed one) beside the executable; the second case says so explicitly.
54+
55+
Note that a list-valued setting **replaces** its default rather than extending it. That is why the bundled `mcp-arch.yaml` declares no `extractors:`, `explainers:` or `renderers:` - a copied list silently falls behind as new ones ship. Set `extractors:` only to deliberately narrow a run; enola warns when an extractor you excluded would have detected the repository.
4556

4657
The install script installs **only the binary**, by design - it does not place a config file. Grab the bundled one from the repo whenever you want to customize (tune the `ignore` globs, pick a subset of extractors, change the output dir, …):
4758

examples/cpp.yaml

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -40,13 +40,13 @@ ignore:
4040
- "**/*.json"
4141
extractors:
4242
- cpp
43-
explainers:
44-
- cycles
45-
- layers
46-
- crossrepo
47-
- coverage
48-
renderers:
49-
- llm_context
43+
# explainers: / renderers: are deliberately absent.
44+
#
45+
# A list-valued key REPLACES the built-in list rather than extending it, so naming
46+
# a subset here silently disables everything that ships beyond it — this file used
47+
# to pin four explainers, turning off the other six (god-class, hotspots,
48+
# unused-routes, dependency-depth, exported-surface, complexity-outliers) for anyone
49+
# who adopted it. Omitting the keys means new plugins arrive automatically.
5050
output:
5151
dir: ".enola"
5252
max_context_tokens: 16000

examples/full.yaml

Lines changed: 17 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
# ignore patterns for every language. Use this as a starting point
55
# and remove sections that don't apply to your project.
66
#
7-
# Supported extractors:
7+
# Supported extractors — all enabled by default, so this file lists none:
88
# - go (detection: go.mod)
99
# - java (detection: pom.xml, build.gradle, or .java sources)
1010
# - kotlin (detection: build.gradle.kts or build.gradle with Kotlin/Android)
@@ -14,6 +14,10 @@
1414
# - rust (detection: Cargo.toml)
1515
# - cpp (detection: .cpp/.hpp/... or CMakeLists.txt/Makefile + header)
1616
# - php (detection: composer.json, WordPress markers, or any .php source)
17+
# - python (detection: pyproject.toml, setup.py, requirements.txt, Pipfile,
18+
# or a tool config — pytest.ini/mypy.ini/tox.ini/setup.cfg)
19+
# - grpc (detection: first-party .proto sources)
20+
# - openapi (detection: a YAML/JSON file whose content is an OpenAPI spec)
1721

1822
repo: "."
1923
ignore:
@@ -97,23 +101,18 @@ ignore:
97101
- "**/Dockerfile*"
98102
- "**/.env*"
99103

100-
extractors:
101-
- cpp
102-
- go
103-
- java
104-
- kotlin
105-
- typescript
106-
- swift
107-
- ruby
108-
- rust
109-
- php
110-
explainers:
111-
- cycles
112-
- layers
113-
- crossrepo
114-
- coverage
115-
renderers:
116-
- llm_context
104+
# extractors: / explainers: / renderers: are deliberately absent.
105+
#
106+
# A list-valued key REPLACES the built-in list rather than extending it, so naming
107+
# a subset here silently disables everything that ships beyond it. Both lists had
108+
# already fallen behind: `extractors:` claimed to enable every language while
109+
# omitting grpc, openapi and python, and `explainers:` pinned four of the ten,
110+
# turning off god-class, hotspots, unused-routes, dependency-depth,
111+
# exported-surface and complexity-outliers for anyone who adopted this file.
112+
#
113+
# The defaults ARE everything, so a config that means "all of it" says nothing at
114+
# all. Narrow the lists only on purpose, as the per-language examples in this
115+
# directory do.
117116
output:
118117
dir: ".enola"
119118
max_context_tokens: 16000

examples/go.yaml

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -29,13 +29,13 @@ ignore:
2929
- "**/.env*"
3030
extractors:
3131
- go
32-
explainers:
33-
- cycles
34-
- layers
35-
- crossrepo
36-
- coverage
37-
renderers:
38-
- llm_context
32+
# explainers: / renderers: are deliberately absent.
33+
#
34+
# A list-valued key REPLACES the built-in list rather than extending it, so naming
35+
# a subset here silently disables everything that ships beyond it — this file used
36+
# to pin four explainers, turning off the other six (god-class, hotspots,
37+
# unused-routes, dependency-depth, exported-surface, complexity-outliers) for anyone
38+
# who adopted it. Omitting the keys means new plugins arrive automatically.
3939
output:
4040
dir: ".enola"
4141
max_context_tokens: 16000

examples/kotlin.yaml

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -38,13 +38,13 @@ ignore:
3838
- "**/.env*"
3939
extractors:
4040
- kotlin
41-
explainers:
42-
- cycles
43-
- layers
44-
- crossrepo
45-
- coverage
46-
renderers:
47-
- llm_context
41+
# explainers: / renderers: are deliberately absent.
42+
#
43+
# A list-valued key REPLACES the built-in list rather than extending it, so naming
44+
# a subset here silently disables everything that ships beyond it — this file used
45+
# to pin four explainers, turning off the other six (god-class, hotspots,
46+
# unused-routes, dependency-depth, exported-surface, complexity-outliers) for anyone
47+
# who adopted it. Omitting the keys means new plugins arrive automatically.
4848
output:
4949
dir: ".enola"
5050
max_context_tokens: 16000

examples/multi-repo.yaml

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -100,13 +100,13 @@ extractors:
100100
- typescript
101101
- swift
102102
- ruby
103-
explainers:
104-
- cycles
105-
- layers
106-
- crossrepo
107-
- coverage
108-
renderers:
109-
- llm_context
103+
# explainers: / renderers: are deliberately absent.
104+
#
105+
# A list-valued key REPLACES the built-in list rather than extending it, so naming
106+
# a subset here silently disables everything that ships beyond it — this file used
107+
# to pin four explainers, turning off the other six (god-class, hotspots,
108+
# unused-routes, dependency-depth, exported-surface, complexity-outliers) for anyone
109+
# who adopted it. Omitting the keys means new plugins arrive automatically.
110110
output:
111111
dir: ".enola"
112112
max_context_tokens: 16000

examples/php.yaml

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -46,13 +46,13 @@ ignore:
4646
- "**/*.md"
4747
extractors:
4848
- php
49-
explainers:
50-
- cycles
51-
- layers
52-
- crossrepo
53-
- coverage
54-
renderers:
55-
- llm_context
49+
# explainers: / renderers: are deliberately absent.
50+
#
51+
# A list-valued key REPLACES the built-in list rather than extending it, so naming
52+
# a subset here silently disables everything that ships beyond it — this file used
53+
# to pin four explainers, turning off the other six (god-class, hotspots,
54+
# unused-routes, dependency-depth, exported-surface, complexity-outliers) for anyone
55+
# who adopted it. Omitting the keys means new plugins arrive automatically.
5656
output:
5757
dir: ".enola"
5858
max_context_tokens: 16000

0 commit comments

Comments
 (0)