enola is a Go codebase of about 540 parsed files: extractors for a long list of languages, linkers that join repositories, explainers that turn the graph into findings, and the MCP server and CLI on top. Most contributions add one extractor or one explainer at a time.
The question we asked was practical. Where is a change most likely to break something, and what would make the next contribution easier? We answered it with enola, on enola, and then checked every answer against the code.
One snapshot took about half a second and produced 10,055 facts and 182 insights. Everything below comes from these calls and from reading the code they pointed at:
generate_snapshot(repo_path=".") package_metrics(output_mode="summary") package_metrics(output_mode="compact", sort_by="distance") query_insights(explainer="package-metrics") query_insights(explainer="god-class") query_insights(explainer="hotspots") explore(focus="internal/facts")
What package metrics measure
The metrics are Robert C. Martin's, the ones JDepend made familiar. For each package:
- Afferent coupling (Ca): how many other packages depend on it.
- Efferent coupling (Ce): how many other packages it depends on.
- Instability (I) = Ce / (Ca + Ce). 0 is maximally stable, 1 maximally unstable.
- Abstractness (A): the share of its types that are interfaces or abstract classes.
- Distance (D) = |A + I − 1|: how far the package sits from the main sequence.
The idea behind the main sequence: a package many others depend on should be abstract, so it can be extended without being edited. A package that depends on many and is depended on by few can be concrete, because nothing breaks when it changes. Packages that are stable and concrete land in the zone of pain; packages that are unstable and abstract land in the zone of uselessness.
enola's explainer documentation is careful about what these numbers are: the arithmetic is exact, and only the claim that a large distance is a problem is an opinion. That distinction shaped every decision below.
The first pass: nine flags, one signal
Across 117 packages that declare types, average instability was 0.51 and average distance 0.48. Nine packages were flagged as off the main sequence (D > 0.7). Sorted by what they are, not by their score:
| Group | Packages (Ca) | Verdict |
|---|---|---|
| Leaf utilities | linkers/vocab (9), pkg/cli (3), pkg/install (1), hookstate (1) | Stable on purpose. They depend on nothing. |
| Data model packages | pkg/history (5), pkg/status (5), intent (9), diff (7) | Flagged by Go's abstractness blind spot |
| The core | internal/facts (83) | Worth a look |
The per-package table had a second kind of noise. Of its 21 rows at D = 1.00, 11 were example fixtures, a scratch directory and packaging stubs that nothing depends on at all. A package with no dependents and no dependencies has no meaningful position on the plane, and ranking it first pushes the real rows down.
Go's blind spot
In Go, interfaces are declared by the consumer, not next to the implementation. A package that is designed to be depended on usually exports concrete types, and the interfaces that abstract over them live in the packages that use them. So abstractness is close to zero nearly everywhere, distance reduces to 1 − I, and “zone of pain” reads as “is depended on”.
On a Go codebase, D is not the number to sort by. Afferent coupling and the package's contents are.
That left one signal: internal/facts, with 49 types, an instability of 0.01, and 83 of the other packages importing it.
The explainer learned from the run
The first pass fed straight back into the tool. The package-metrics explainer now requires at least five dependents before it calls a package rigid. After a rebuild, the same snapshot produced six flags instead of nine, and 179 insights instead of 182. hookstate, pkg/cli and pkg/install dropped out, and each remaining finding now states how many packages depend on it.
The threshold removes some noise, but three known false positives remain:
linkers/vocabis still flagged. It is a leaf utility with nine dependents, stable by design, and a threshold on Ca cannot tell it apart from a hub that is a problem.- The per-package table still leads with the rows nothing depends on. The insights apply the new rule; the table does not yet filter the same rows.
pkg/historyandpkg/statusclear the new threshold at exactly five dependents.
Finding 1: one helper, ten copies, one without a nil check
The god-class explainer reported 25 high fan-in symbols. Most were the same two helpers repeated in every tree-sitter extractor: kindOf, which reads a syntax node's type, and nodeText, which reads its source text. The TypeScript extractor's kindOf alone had 121 dependents.
The two looked identical in the report. They were not.
Every kindOf was already a one-line wrapper over a shared table in tsutil. High fan-in on a wrapper is not a design problem, so that half was a false positive.
nodeText was different. It existed in ten copies: seven named nodeText, plus pyText, phpText and rubyText. Nine returned an empty string for a nil node. The TypeScript copy did not:
func nodeText(node *sitter.Node, src []byte) string {
return string(src[node.StartByte():node.EndByte()])
}
Optional children come back nil: a missing name, an absent field. And there is no recover() anywhere on the extraction path, so a nil node in that extractor would have aborted the whole snapshot, not one file. Nothing had hit it yet, which also meant no cache change was needed: any snapshot that completed had produced exactly the facts the fixed code produces.
The fix is one helper in the shared package, with the reason for it written down:
// Text returns the source text a node covers, or "" for a nil node.
//
// Optional children (ChildByFieldName, a missing name) come back nil, so every
// extractor needs the guard. Keeping one copy here stops the copies drifting: the
// TypeScript extractor's own version once lacked it and panicked on a nil child.
func Text(node *sitter.Node, src []byte) string {
if node == nil {
return ""
}
return string(src[node.StartByte():node.EndByte()])
}
The ten local helpers kept their names as one-line wrappers, the same pattern kindOf already used, so no call site changed. The next person writing an extractor finds one helper instead of ten slightly different ones.
Finding 2: the refactor we did not do
Facts carry their properties in a map[string]any, keyed by plain strings. The obvious risk is a misspelled key: a read of a key nobody writes returns the zero value, silently. No crash, no failing test, just missing facts.
Our first measurement used grep. It counted about 940 string-literal call sites and 246 distinct keys, and produced a long list of keys that were read but apparently never written. That looked like a strong case for typed key constants across the codebase.
Before planning a change that size, we measured again with a parser. The grep had counted writes as reads and had never seen writes that build a map first and assign it whole. An AST scan that tracked reads through the property accessors and writes through SetProp and Props literals found 109 keys read with no visible write. Counting writes into local maps as well brought it to 4. Checked by hand, all four were fine:
rootandreached_fromare written through a small local helper.generatedis written by a bulk setter on the store.frame_labelarrives in runtime-capture data, outside Go entirely.
Zero real defects. Converting roughly 900 call sites to typed keys would have fixed a problem that was not producing bugs, so we dropped it.
The scan did surface something else. A comment on the property accessors said every write goes through SetProp, and that this single seam was what made the map's representation changeable. Neither was true: most extractors build a local map and assign it whole. The representation is a real candidate for change, since it is 1.89 million small maps on a kernel-sized graph, and anyone attempting that would have trusted the comment and missed hundreds of writers. The comment now says what the code does:
// SetProp and DelProp take a POINTER receiver so they can create the map on a fact // that has none. They are NOT the only way props get written: most extractors build a // local map[string]any (a literal, or props["k"] = v) and assign it to Props whole, and // Store.SetPropRange writes in bulk.
A number from a text search is a hypothesis about the code. Check it with a parser before planning work around it.
Finding 3: splitting the package everything imports
internal/facts held the fact model and the store, but also a graph index, the vocabulary shared between extractors and linkers, endpoint impact analysis, glob matching, repository identity and route-path joining. The metrics said it was the hub. They could not say which parts were free to leave.
For that we mapped which symbols each file inside the package uses from the others, with a short Go AST script, and counted each concern's consumers outside it:
| File | Uses inside the package | Used outside by | Decision |
|---|---|---|---|
graph.go | model, accessors; the store depends on it | 9 packages, 47 sites | Stays |
contract.go | model | 6 packages | Stays |
endpoint.go | exported store and graph API only | 4 packages | Moves |
pathglob.go | nothing | 3 packages | Moves |
The graph looked like the obvious candidate at 1,810 lines. But the store owns the graph index: it builds it, invalidates it, and delegates reverse lookups to it. Moving the graph out means inverting that ownership across 47 call sites, and an index belongs with the thing it indexes. contract.go stays for a different reason: it is the declared vocabulary that extractors write and linkers read, and the package everything imports is where it should live.
Endpoint analysis, a route to controller to model walk of 454 lines, uses only the exported API. It moved to internal/endpoint, and Store.AnalyzeEndpoint became endpoint.Analyze. The JSON it returns did not change. The glob matcher, 386 lines with no dependencies, moved to internal/pathglob, with the redundant Glob dropped from its names.
Each new package also went into enola-intent.yaml, the declared layer order that CI gates on: endpoint in the analysis layer, pathglob in core. A package missing from that file is not checked. Re-running the layers explainer confirmed both were mapped and that no import runs against the order.
What the number did
The package went from 49 types to 44. Its afferent coupling went from 83 to 84.
That is not a failure of the refactor. Nearly every package imports internal/facts for the Fact type, so moving code out cannot reduce the count, and the new endpoint package is one more importer. The gain is not in the metric: 840 lines of heuristics no longer sit in the package 84 others import, and a change to endpoint matching is now reviewed as a change to endpoint matching.
Afferent coupling told us where the risk was concentrated. It was never the number to push down.
Checking that nothing moved
The unit tests and the linter passed after every step. That shows the code compiles and the tested behaviour holds. It does not show that extraction is unchanged on real repositories, where most of the variety lives.
For that we built two binaries from the same base commit, one before the changes and one after, and ran both through the benchmark harness:
| Script | What it checks | Result |
|---|---|---|
bench-sweep.sh | 91 public repositories, each indexed three times: cold, warm, warm | Facts byte-identical on all 91. No observed output regressions. 91/91 reproduce. |
bench-ratchet.sh | 20 repositories graded in four states against a pinned baseline | Same findings and same exit codes under both binaries |
bench-newsurface.sh | 24 command contracts, including the endpoint command | 24/24 pass |
Each sweep took about 25 minutes on a laptop. The comparison flags a drop in any single fact kind or language, not only in the total, so a refactor that quietly lost seven routes while gaining nothing else would not have passed.
What to take to your own codebase
- Drop packages nothing depends on before ranking by distance. Their position on the plane means nothing.
- On Go, read distance as instability. Sort by afferent coupling and look at what the package contains.
- Treat afferent coupling as a map of risk, not a target. Splitting a hub rarely lowers it. It can still make the hub smaller and safer to edit.
- Check each flag against the code.
kindOfandnodeTextlooked identical in the report. One was noise, the other a latent crash. - Measure with a parser before planning work around a number. Our grep pointed at a 900-site refactor. The AST scan found nothing to fix.
- Verify on real repositories. Build before and after from the same commit and compare output, not just test results.
FAQ
Can a refactor change what enola extracts?
It can, which is why unit tests are not enough to verify one. Build the binary before and after from the same base commit, run both over real repositories, and compare the output hashes. For this refactor, the extracted facts were byte-identical on all 91 public repositories in the benchmark corpus, with no observed output regressions.
Does enola decide what to refactor?
No. The package metrics are exact arithmetic over the dependency graph. Whether a large distance is a problem is an opinion, so the findings are advisory. In this run, each decision came from reading the code behind a flag: one flag pointed at real work, one helper flagged ten times was a false positive, and one planned refactor was dropped after a better measurement.
Related reading
- Dead code is a context tax. The same method applied to deletion: findings as candidates, checked against the code.
- How we test Enola against 91 repositories. What each benchmark script used above checks, and why it exists.
- What a dependency graph can and cannot tell you. The structure these metrics are computed over.
- Whole-repository explainers. Package metrics, dead code and performance, and what each one can and cannot claim.
enola is open source under Apache 2.0. Free, local, no data leaves your machine.
curl -fsSL https://raw.githubusercontent.com/enola-labs/enola/main/install.sh | sh