enolaBlogWhat package metrics said about enola's own code

package metrics

What package metrics said about enola's own code.

We ran enola's package metrics against its own 117 typed packages. Nine were flagged; after reading the code, only internal/facts warranted structural work. Following the trail through related explainers also exposed ten copies of a helper, one missing a nil guard, and killed a planned 900-call-site refactor. We shipped the useful changes and found no output differences across 91 public repositories.

Dejan10 min read

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:

mcp
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 packages plotted against the main sequence Instability on the horizontal axis, abstractness on the vertical axis. Almost every package sits on the bottom edge, where abstractness is zero. Zone of pain Zone of uselessness Main sequence 0 1 1 Instability I Abstractness A clientspec drift history · status intent · diff internal/facts · Ca 83 bootstrap · server · command
A selection of enola's packages on the I/A plane. Almost everything sits on the bottom edge: in Go, abstractness is close to zero nearly everywhere, so distance mostly measures stability.

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:

GroupPackages (Ca)Verdict
Leaf utilitieslinkers/vocab (9), pkg/cli (3), pkg/install (1), hookstate (1)Stable on purpose. They depend on nothing.
Data model packagespkg/history (5), pkg/status (5), intent (9), diff (7)Flagged by Go's abstractness blind spot
The coreinternal/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/vocab is 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/history and pkg/status clear 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:

go · tsextractor, before
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:

go · tsutil/text.go
// 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:

  • root and reached_from are written through a small local helper.
  • generated is written by a bulk setter on the store.
  • frame_label arrives 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:

go · facts/accessors.go
// 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:

FileUses inside the packageUsed outside byDecision
graph.gomodel, accessors; the store depends on it9 packages, 47 sitesStays
contract.gomodel6 packagesStays
endpoint.goexported store and graph API only4 packagesMoves
pathglob.gonothing3 packagesMoves

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:

ScriptWhat it checksResult
bench-sweep.sh91 public repositories, each indexed three times: cold, warm, warmFacts byte-identical on all 91. No observed output regressions. 91/91 reproduce.
bench-ratchet.sh20 repositories graded in four states against a pinned baselineSame findings and same exit codes under both binaries
bench-newsurface.sh24 command contracts, including the endpoint command24/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

  1. Drop packages nothing depends on before ranking by distance. Their position on the plane means nothing.
  2. On Go, read distance as instability. Sort by afferent coupling and look at what the package contains.
  3. 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.
  4. Check each flag against the code. kindOf and nodeText looked identical in the report. One was noise, the other a latent crash.
  5. 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.
  6. 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

enola is open source under Apache 2.0. Free, local, no data leaves your machine.

shell
curl -fsSL https://raw.githubusercontent.com/enola-labs/enola/main/install.sh | sh