# Benchmarks
This page carries two kinds of evidence: a field measurement from real coding-agent
sessions, and a repeatable fixed-corpus benchmark of enola's structural verdicts.
The fixed corpus was measured on 2026-10-12, at extractor version v332, on 95 public
open-source repositories, with one binary, by scripts you can re-run. It carries the
latest sweep rather than a released version's, so it moves when the extractors do.
Where a number is unflattering it is still here.
## Real-session usage
Between February and October 2026, the logs written by Claude Code and OpenCode
recorded 4,375 enola calls across 238 sessions of ordinary development. Nothing was
staged or re-run.
Claude Code used the latest Claude Opus version available at the time throughout.
OpenCode ran local 27B-class Qwen models throughout: Qwen 3.8 27B at the end of the
measurement period, Qwen 3.6 27B before it, and Qwen Coder earlier.
| | Claude Code | OpenCode | Total |
|---|---:|---:|---:|
| Sessions | 32 | 206 | **238** |
| enola calls | 774 | 3,601 | **4,375** |
| Tokens enola put into context | 389k | 3.7M | **4.1M** |
| Opening only the files named in each answer | 5.4× as much | 7.7× as much | — |
| Reading the full source represented by each session | 450× as much | 623× as much | — |
| Snapshot sessions whose source exceeded median active context | 27 / 27 | 178 / 183 | **205 / 210** |
The median call was about 220 to 250 tokens. Most graph lookups completed in tens of
milliseconds; generating a snapshot took a median of 1.3 to 3.1 seconds. The full-source
comparison applies to questions that need repository-wide structure. The named-file
comparison is the narrower floor: it assumes the agent already knew which files to open,
and prices only those files.
The payload and latency come from the agents' own logs. No harness records tokens per
tool call, so tokens are estimated as characters sent and returned divided by four. The
two source-reading columns use repository source sizes and each harness's observed
behaviour when reading files. They describe this workload; another developer's repositories,
models and working style will produce different numbers under the same accounting.
The [full field report](https://enola.tech/blog/what-enola-saves-in-real-agent-sessions)
breaks the result down by tool and by one- versus multi-repository sessions.
## What is measured, and why these five things
A code graph for agents is usually benchmarked on retrieval: how few tool calls,
how few tokens, how fast the agent finds the right file. The field measurement above
answers those questions for the workload that occurred. The fixed corpus below asks
whether the structural verdict an agent receives can be believed.
Its timings are the engine's own snapshot `duration` rather than wall-clock. The sweep
passes `--memstats`, which forces a full GC before reporting its figures, and on a
kernel-sized heap that is a few hundred milliseconds of process time no user pays.
The questions below are the ones that decide whether a *structural verdict* about
your change can be believed:
| | The question | Why it cannot be skipped |
|---|---|---|
| **1** | Does the same commit produce the same graph? | A diff between two snapshots is meaningless if the snapshots are not reproducible |
| **2** | When something regresses, is exactly that reported? | A gate that also reports pre-existing problems gets switched off in a week |
| **3** | Of the cross-repo edges that exist, how many are found — and are the misses shown? | A resolved-only count is advertising |
| **4** | Does it finish on a real repository? | If it can't parse a real codebase, there's nothing to report on |
| **5** | Does an agent, given the tool, actually avoid shipping the regression? | The honest test of the whole idea |
Axes 1–3 have no counterpart in a retrieval benchmark, because you cannot report a
delta without a baseline, and you cannot report a miss without knowing what a hit
would have been.
## The corpus
95 repositories, 468,899 source files parsed of 767,771 seen, 8,355,653 facts
carrying 26 distinct language tags (Ansible, C, C++, C#, Dart, F#, Go, HCL, Java,
Kotlin, Markdown, PHP, Python, Razor, Ruby, Rust, Scala, SQL, Stimulus, Swift,
TypeScript, VB.NET, XAML, gRPC, OpenAPI, and enola's own intent pages). Public open-source only: every row is a
repository you can clone and re-run.
**95 of 95 reproduce** — identical `snapshot_id` and identical
`facts.jsonl` hash across a cold run and two warm ones, i.e. across cache states.
**And 81 of 81 reproduce across separate sweeps**, measured when 0.4.0 was validated:
that sweep ran twice, forty minutes apart, and every repository's `facts.jsonl` was
byte-identical between the two runs. That is a stronger claim than the one above, which compares
three runs inside a single sweep: it holds across process lifetimes and a machine
whose memory and page cache had moved on. One caveat stated rather than hidden —
`snapshot_id` covers the fact stream, not the receipt's `file_hashes` list, so this
result says nothing about that field's stability.
This is one sweep, not a sum of several: the Dart/Flutter rows that were previously
measured separately are folded in here, so every number on this page comes from the
same run of the same binary.
**Ruby grew from four repositories to thirteen**, which is what surfaced the Rails
work described below. The four it had — gitlab, discourse, chatwoot, solidus — shared
one shape between them, a single application with a single root `config/routes.rb`,
and three defects hid behind that. See [the Ruby rows](#ruby--rails).
**PHP grew from eight repositories to twelve**, and the four are Laravel applications.
The one Laravel repository it had, cachet, is since 3.x an application shell whose
controllers and models live in a vendored package, so nothing in the corpus could show
whether a Laravel route reaches a model. See [the Laravel rows](#php--laravel).
**TypeScript is now the largest language after C** — 1,197,480 facts against C's
2,075,190 (almost all of it the Linux kernel). It was 781,437 at v224, and the jump is
the v253 detection change rather than a corpus change: extractors used to answer
`Detect` with their own bounded walk, so loose `.js` and `.ts` below a scan depth were
claimed and never read. It overtakes both the .NET family — 1,122,993 facts tagged C#,
VB.NET, F#, Razor/Blazor or XAML — and C# alone at 954,285, each of which held the
position at v224. Dart is unchanged at 792,439. Every figure in this paragraph counts
facts by **language tag**, not by repository: a per-language section below totals
everything its repositories contain, which is a larger and different number.
See [the .NET rows](#net) below.
| Repository | Language | Files parsed | Facts | Cold | Warm |
|---|---|---|---|---|---|
| linux | c | 57,480 | 2,068,619 | 142.1s | 37.9s |
| gitlab | ruby | 55,949 | 551,664 | 37.4s | 23.0s |
| runtime | csharp | 23,678 | 537,421 | 54.7s | 27.7s |
| dart-sdk | dart | 16,892 | 454,572 | 47.6s | 8.7s |
| rust | rust | 37,669 | 391,491 | 15.2s | 8.5s |
| roslyn | csharp | 17,517 | 376,527 | 14.9s | 10.5s |
| shopware | php | 19,908 | 238,940 | 8.8s | 5.2s |
| spark | scala | 5,749 | 219,995 | 8.4s | 6.5s |
| flutter | dart | 7,886 | 211,519 | 7.3s | 5.3s |
| grafana | go | 11,379 | 190,048 | 8.8s | 4.5s |
| thingsboard | java | 6,620 | 163,570 | 5.0s | 3.1s |
| discourse | ruby | 13,258 | 144,727 | 9.3s | 5.1s |
| flutter-packages | dart | 3,942 | 136,360 | 6.0s | 3.7s |
| gauzy | angular | 8,915 | 120,923 | 6.4s | 2.7s |
| openproject | ruby | 11,289 | 110,401 | 8.6s | 5.5s |
| nextcloud-server | php | 6,071 | 104,065 | 5.1s | 2.0s |
| pekko | scala | 2,296 | 97,657 | 2.4s | 2.0s |
| airflow | python | 13,637 | 95,222 | 8.5s | 5.7s |
| bitwarden-clients | typescript | 5,085 | 84,879 | 4.0s | 2.1s |
| dubbo | java | 4,374 | 82,958 | 1.9s | 1.7s |
| ente | dart | 3,755 | 80,368 | 3.6s | 1.8s |
| angular | angular | 7,126 | 79,622 | 4.8s | 2.1s |
| supabase | typescript | 7,387 | 79,171 | 6.7s | 2.4s |
| spartacus | angular | 7,273 | 75,509 | 3.9s | 2.2s |
| deno | rust | 4,817 | 70,880 | 5.5s | 2.0s |
| fsharp | fsharp | 1,916 | 69,844 | 2.1s | 1.5s |
| avalonia | xaml | 3,883 | 64,860 | 2.5s | 1.4s |
| superset | python | 4,324 | 61,375 | 4.8s | 2.6s |
| orchardcore | razor | 7,311 | 60,646 | 3.8s | 1.7s |
| appflowy | dart | 2,510 | 57,197 | 1.6s | 1.2s |
| lila | scala | 2,345 | 55,404 | 2.5s | 1.5s |
| cal.com | typescript | 4,989 | 52,357 | 3.0s | 1.4s |
| dbt-core | rust | 1,507 | 49,241 | 2.0s | 1.0s |
| wordpress | php | 2,775 | 45,859 | 3.5s | 1.1s |
| gmsh | cpp | 1,765 | 43,683 | 2.1s | 0.9s |
| peertube | angular | 2,833 | 43,554 | 25.4s | 0.9s |
| gitea | go | 5,540 | 43,486 | 2.2s | 1.2s |
| powershell | csharp | 1,302 | 42,833 | 2.1s | 1.1s |
| bitwarden-server | csharp | 3,704 | 42,187 | 2.0s | 1.4s |
| chatwoot | ruby | 4,009 | 39,996 | 2.3s | 1.3s |
| mudblazor | razor | 3,184 | 38,836 | 1.5s | 0.7s |
| immich | dart | 2,170 | 38,831 | 1.7s | 0.9s |
| rails | ruby | 2,673 | 38,020 | 1.9s | 1.4s |
| taiga-ui | angular | 2,985 | 35,498 | 1.7s | 1.0s |
| flutterfire | dart | 1,188 | 33,036 | 1.5s | 0.9s |
| angular-components | angular | 2,276 | 31,570 | 1.7s | 0.9s |
| mastodon | ruby | 3,444 | 31,000 | 2.5s | 1.3s |
| saleor | python | 2,502 | 30,909 | 2.3s | 1.5s |
| flarum | php | 2,735 | 29,248 | 1.2s | 0.6s |
| zio | scala | 1,137 | 28,292 | 1.1s | 0.7s |
| mcp | csharp | 2,315 | 28,080 | 1.4s | 0.9s |
| cognee | python | 2,223 | 28,026 | 3.2s | 2.0s |
| jellyfin | csharp | 1,892 | 27,628 | 1.0s | 0.6s |
| koel | php | 2,226 | 26,482 | 1.0s | 0.5s |
| firefly-iii | php | 1,600 | 24,953 | 1.4s | 0.5s |
| pekko-http | scala | 1,028 | 23,763 | 0.7s | 0.6s |
| spotube | dart | 457 | 22,059 | 0.6s | 0.4s |
| monica | php | 1,695 | 20,969 | 0.8s | 0.4s |
| drift | dart | 917 | 20,457 | 0.9s | 0.5s |
| files | xaml | 1,286 | 17,397 | 0.8s | 0.4s |
| cognee-rs | rust | 1,016 | 17,346 | 0.8s | 0.4s |
| bookstack | php | 1,118 | 16,519 | 1.1s | 0.4s |
| fastlane | ruby | 1,073 | 15,915 | 1.1s | 0.6s |
| openwhisk | scala | 484 | 15,298 | 0.6s | 0.4s |
| tokio | rust | 819 | 14,726 | 0.5s | 0.3s |
| http4s | scala | 474 | 13,857 | 0.6s | 0.3s |
| crates-io | rust | 945 | 13,032 | 0.7s | 0.3s |
| solidus | ruby | 2,036 | 12,952 | 1.2s | 0.6s |
| enola | go | 630 | 12,198 | 10.0s | 0.4s |
| excalidraw | typescript | 1,209 | 10,977 | 1.3s | 0.4s |
| localsend | dart | 449 | 10,738 | 0.5s | 0.2s |
| ngrx | angular | 1,077 | 10,466 | 0.7s | 0.3s |
| dashboard | angular | 641 | 10,097 | 0.8s | 0.3s |
| rubygems.org | ruby | 1,212 | 6,801 | 0.6s | 0.3s |
| nextcloud-collectives | php | 414 | 5,889 | 0.5s | 0.2s |
| gitbucket | scala | 238 | 5,857 | 0.3s | 0.2s |
| isowords | swift | 387 | 5,836 | 0.3s | 0.2s |
| csharp-sdk | csharp | 498 | 5,402 | 0.4s | 0.3s |
| nowinandroid | kotlin | 356 | 5,333 | 0.3s | 0.2s |
| getdp | cpp | 184 | 4,754 | 0.6s | 0.1s |
| eshop | csharp | 585 | 3,620 | 0.3s | 0.2s |
| elk | vue | 398 | 3,200 | 0.2s | 0.1s |
| lobsters | ruby | 541 | 2,902 | 0.4s | 0.2s |
| ngx-admin | angular | 254 | 2,818 | 0.2s | 0.1s |
| grape | ruby | 201 | 2,424 | 0.2s | 0.2s |
| activeadmin | ruby | 295 | 2,424 | 0.2s | 0.1s |
| trading | scala | 128 | 2,257 | 0.1s | 0.1s |
| nextcloud-contacts | php | 177 | 2,025 | 0.3s | 0.1s |
| ng-alain | angular | 139 | 1,812 | 0.1s | 0.1s |
| devise | ruby | 177 | 1,403 | 0.1s | 0.1s |
| giraffe | fsharp | 48 | 1,241 | 0.1s | 0.1s |
| grpc-web-example | grpc | 15 | 339 | 0.1s | 0.0s |
| sveltekit-realworld | svelte | 45 | 226 | 0.1s | 0.0s |
| cachet | php | 30 | 161 | 0.1s | 0.0s |
| orocrm | php | 8 | 124 | 0.1s | 0.0s |
### Ruby / Rails
Thirteen repositories, 96,157 files parsed, 960,629 facts of which **556,132 are Ruby**.
All thirteen reproduce.
| Repository | Files parsed | Facts | Ruby facts | rails routes | grape routes |
|---|---:|---:|---:|---:|---:|
| gitlab | 55,949 | 551,664 | 306,769 | 2,059 | 1,554 |
| discourse | 13,258 | 144,727 | 69,912 | 1,552 | 0 |
| openproject | 11,289 | 110,401 | 74,437 | 1,678 | 389 |
| mastodon | 3,444 | 31,000 | 17,438 | 777 | 0 |
| rails | 2,673 | 38,020 | 33,668 | 32 | 0 |
| chatwoot | 4,009 | 39,996 | 14,364 | 690 | 0 |
| solidus | 2,036 | 12,952 | 11,625 | 690 | 0 |
| rubygems.org | 1,212 | 6,801 | 6,292 | 239 | 0 |
| lobsters | 541 | 2,902 | 2,749 | 246 | 0 |
| grape | 201 | 2,424 | 1,932 | 0 | 5 |
| fastlane | 1,073 | 15,915 | 13,690 | 0 | 0 |
| devise | 177 | 1,403 | 1,314 | 0 | 0 |
| activeadmin | 295 | 2,424 | 1,942 | 0 | 0 |
**The four zero rows are the result, not a gap.** `fastlane` is scale Ruby with no Rails
at all — the control for whether a Rails change breaks plain Ruby. `devise` and
`activeadmin` are engines whose only `routes.rb` files sit under `lib/` and *define*
routing DSL rather than declaring routes. `grape` yields 5 routes from 1,596 verb sites
for the same reason: `lib/grape/**` implements the DSL, and the specs that exercise it
are excluded as tests. A library defines a DSL and does not use one, so validating a
route extractor against the library that implements it measures the wrong thing — the
lesson the Scala corpus learned, applied here from the start. gitlab and openproject are
what grade the extractor; `rails`' own 23 routes come from `activestorage` and
`actionmailbox`, the two gems that ship a real route file.
#### What the nine new repositories exposed
The corpus was four repositories until this run, and all four shared one shape — a single
application with a single root `config/routes.rb`. Three defects hid behind that, and a
fourth hid behind the assumption that a route file contains only route DSL:
| | before | after |
|---|---:|---:|
| solidus route files read | 0 / 5 | **5 / 5** |
| solidus rails routes | 0 | **735** |
| discourse route files read | 1 / 26 | **26 / 26** |
| discourse rails routes | 1,195 | **1,729** |
| gitlab grape routes | 0 | **1,554** |
| openproject grape routes | 0 | **389** |
| discourse layer violations | 426 | **27** |
A Rails route table is not one file. Routes are now collected from every
`
/config/routes.rb` and every `.rb` below a `config/routes/` directory at any depth,
each parsed under the prefix it is actually served at — `draw(:pkg)` followed
transitively, and `mount SomeEngine, at: '/x'` resolved to the directory owning that
constant so the engine's whole route table is parsed below `/x`. Routes also carry a
handler and a `handled_by` edge to the controller action, without which a Rails route is
an isolated node and a controller reached only through the route table reads as dead
code. The walk descends through Ruby control flow, because a route file *is* Ruby and
real ones guard whole blocks with `if`/`unless` — Rails' own
`activestorage/config/routes.rb` is a `draw` block closed by an `if` modifier, and five
route files across the corpus parsed cleanly and produced nothing.
The layer row is not an extractor change. discourse is a Rails backend beside an Ember
frontend, the ember-octane pattern wins the repo on confidence, and layer matching is by
path segment with no notion of language — so with `lib` in Ember's level-0 util layer,
every Ruby `lib/` and `plugins/*/lib/*` directory became the innermost layer and each
model or service it legitimately called became a violation. 397 of 426 were that.
**Measured parse coverage.** Every `.rb`/`.rake` file in all thirteen repositories,
parsed with the pinned `tree-sitter-ruby` v0.23.1 and its error nodes attributed to the
nearest enclosing definition: **87,729 files, 0.04% with any error, 0.01% losing a
type** — the column that matters, and materially cleaner than the Scala corpus. The
conclusion that bought is the important one: the grammar was never the problem, and
every gap above was in extraction logic.
### Dart / Flutter
Ten repositories, 40,166 files, 1,065,137 facts of which **792,041 are Dart**. All ten
reproduce, and **all ten parse with zero errors** — see the corpus notes in
[`enola-benchmarks`](https://github.com/enola-labs/enola-benchmarks) for the
up-front parse-coverage measurement that preceded the extractor.
| Repository | Files parsed | Facts | Dart facts | Cold | Warm |
|---|---|---|---|---|---|
| dart-sdk | 16,892 | 454,572 | 394,478 | 47.6s | 8.7s |
| flutter | 7,886 | 211,519 | 143,735 | 7.3s | 5.3s |
| flutter-packages | 3,942 | 136,360 | 96,074 | 6.0s | 3.7s |
| ente | 3,755 | 80,368 | 41,595 | 3.6s | 1.8s |
| appflowy | 2,510 | 57,197 | 40,484 | 1.6s | 1.2s |
| immich | 2,170 | 38,831 | 14,479 | 1.7s | 0.9s |
| flutterfire | 1,188 | 33,036 | 13,645 | 1.5s | 0.9s |
| spotube | 457 | 22,059 | 21,272 | 0.6s | 0.4s |
| drift | 917 | 20,457 | 18,703 | 0.9s | 0.5s |
| localsend | 449 | 10,738 | 7,576 | 0.5s | 0.2s |
dart-sdk is the outlier on cold time and not because of its Dart: it carries 1,138
C/C++ sources and a large `runtime/` tree, so the C/C++ extractor does substantial work
on it too. Its warm run is 4.8x faster than cold.
Two rows are cross-repo clusters rather than single applications. **immich** pairs a
Flutter client with a TypeScript server and **ente** pairs one with a Go server; split
into their halves, ente's client resolves 167 of 168 outbound call sites against its
own backend (see [Cross-repo resolution](#3-cross-repo-resolution-misses-included)).
**flutter-packages, flutterfire, drift and spotube are almost pure Dart** (96,074 of
96,081 facts; 13,645 of 13,677; 18,703 of 18,703; 21,272 of 21,528), which makes them
the rows where a Dart extraction regression shows up undiluted.
### .NET
Fourteen repositories, 69,119 files, 1,316,522 facts — everything those repositories
contain, markdown and configuration included. Counted by language tag instead, C#,
VB.NET, F#, Razor and XAML together carry 1,122,993 of them; the corpus section above
ranks the languages on that basis. All fourteen reproduce.
| Repository | Files parsed | Facts | Cold | Warm |
|---|---|---|---|---|
| runtime | 23,678 | 537,421 | 54.7s | 27.7s |
| roslyn | 17,517 | 376,527 | 14.9s | 10.5s |
| fsharp | 1,916 | 69,844 | 2.1s | 1.5s |
| avalonia | 3,883 | 64,860 | 2.5s | 1.4s |
| orchardcore | 7,311 | 60,646 | 3.8s | 1.7s |
| powershell | 1,302 | 42,833 | 2.1s | 1.1s |
| bitwarden-server | 3,704 | 42,187 | 2.0s | 1.4s |
| mudblazor | 3,184 | 38,836 | 1.5s | 0.7s |
| mcp | 2,315 | 28,080 | 1.4s | 0.9s |
| jellyfin | 1,892 | 27,628 | 1.0s | 0.6s |
| files | 1,286 | 17,397 | 0.8s | 0.4s |
| csharp-sdk | 498 | 5,402 | 0.4s | 0.3s |
| eshop | 585 | 3,620 | 0.3s | 0.2s |
| giraffe | 48 | 1,241 | 0.1s | 0.1s |
The corpus is deliberately split so each mechanism has a control:
| Repo | What it is the control for |
|---|---|
| jellyfin | ASP.NET Core **attribute** routing — 420 routes |
| eShop | **minimal APIs** with `MapGroup` prefixes, EF Core, .NET Aspire |
| OrchardCore | **conventional** MVC routing, Razor Pages — 94% of its controllers carry no `[Route]` |
| roslyn | **VB.NET at scale** — its VB compiler is the largest VB codebase in the open |
| fsharp, Giraffe | **F#**; Giraffe ships no `.csproj` at all |
| MudBlazor | **Blazor** components — 1,987 `.razor` against 1,154 `.cs` code-behind |
| Avalonia, Files | **XAML** — Avalonia at scale, Files as a real WinUI 3 MVVM app |
| bitwarden-server | EF Core + Dapper; the backend half of the cross-repo cluster |
| runtime, PowerShell, mcp, csharp-sdk | scale, `partial`-heavy code, a 148-project monorepo, a library serving no HTTP |
| lila | Play `conf/routes` — one file declaring 923 endpoints, with four included sub-routers |
| openwhisk, pekko-http | a Pekko HTTP **application** against the library that defines the DSL |
| trading, http4s | the same pairing for http4s, and an effect-heavy Scala 3 codebase |
| spark, pekko | Scala **beside Java** in one tree — 1,355 and 582 `.java` in the same packages |
**giraffe is the row that matters most.** It produced **0 facts** before this work —
detection matched its `.slnx`, the extractor claimed the repository, and reported a
successful snapshot of 46 unread F# sources. A green snapshot of nothing is worse
than an unsupported language, because nothing in the output says so.
### Angular
Ten repositories, 33,519 files parsed, 411,869 facts. All ten reproduce; the largest
peaks at 438 MiB.
| Repository | Files parsed | Facts | Cold | Warm |
|---|---|---|---|---|
| gauzy | 8,915 | 120,923 | 6.4s | 2.7s |
| angular | 7,126 | 79,622 | 4.8s | 2.1s |
| spartacus | 7,273 | 75,509 | 3.9s | 2.2s |
| peertube | 2,833 | 43,554 | 25.4s | 0.9s |
| taiga-ui | 2,985 | 35,498 | 1.7s | 1.0s |
| angular-components | 2,276 | 31,570 | 1.7s | 0.9s |
| ngrx | 1,077 | 10,466 | 0.7s | 0.3s |
| dashboard | 641 | 10,097 | 0.8s | 0.3s |
| ngx-admin | 254 | 2,818 | 0.2s | 0.1s |
| ng-alain | 139 | 1,812 | 0.1s | 0.1s |
The corpus is split so that each mechanism has a control, the same way the .NET one is:
| Repo | What it is the control for |
|---|---|
| angular, components | the framework and the component library that **define** the decorators and the router — a library defines a DSL and does not use one |
| ngx-admin, ng-alain | classic **NgModule** applications; ngx-admin indexes in 0.4s and is the row to iterate against |
| taiga-ui | **standalone components and signals** throughout — 153 `standalone: true`, 213 `loadComponent`, and every route path written as an enum member |
| spartacus | library *and* application — 924 NgModules, and the control for a repository whose only `forRoot` calls are in specs |
| gauzy | the **Nx workspace** shape — 99 projects, a `ROUTES` provider factory, and workspace-package aliases |
| peertube, dashboard, gauzy | Angular against an **Express**, a **Go** and a **NestJS** backend |
| ngrx | a DI/state library with no application around it |
**peertube's 33.0s cold against a 1.8s warm** is the row to watch, and it is not the
Angular work: the same repository took 29.6s cold before any of it, and the cost is
its OpenAPI specification plus a 2,825-file TypeScript tree.
**Two rows correctly report almost nothing.** spartacus produces no routes at all —
its only `RouterModule.forRoot` calls live in `.spec.ts` files, and its routing is
configuration its consuming application applies. ngx-admin resolves 51% of its
template references because the other half name components from a third-party library
that lives in `node_modules`. Both numbers are reported with the cause named rather
than folded into a total.
### PHP / Laravel
Five repositories, of which four are new: 6,639 files parsed and 88,923 facts
across the four, 1,533 Laravel routes. All five reproduce, and the largest peaks at
83 MiB.
| Repository | Files parsed | Facts | laravel routes | with a handler | bound to a method | models | relations | endpoint reaches a model |
|---|---:|---:|---:|---:|---:|---:|---:|---:|
| firefly-iii | 1,600 | 24,953 | 640 | 640 | 633 | 51 | 191 | 91 of 200 |
| bookstack | 1,118 | 16,519 | 340 | 340 | 339 | 35 | 54 | 150 of 200 |
| monica | 1,695 | 20,969 | 334 | 333 | 325 | 67 | 206 | 163 of 200 |
| koel | 2,226 | 26,482 | 219 | 211 | 173 | 25 | 36 | 164 of 200 |
| cachet | 30 | 161 | 0 | 0 | 0 | 0 | 0 | 0 of 0 |
`bound to a method` counts the routes carrying a `handled_by` edge to a method the
repository declares. The last column is `enola endpoint` asked about a sample of each
application's routes, one route per query: it counts the ones whose chain runs from the
route through its handler to at least one Eloquent model. The rest stop at the
handler's dependencies, apart from 14 of the 800 that could not be asked about alone:
the query is a substring match, and a shorter path answered first. None stops at the
controller.
**cachet's zero row is the control.** Since 3.x it is an application shell: its
controllers, models and routes live in a package under `vendor/`, which is not indexed.
Each of the four is here for a shape the others lack:
| Repo | What it is the control for |
|---|---|
| koel | route files the skeleton does not name (`api.base.php`, `subsonic.php`), loaded by a computed file name; invokable controllers; services and repositories injected by constructor |
| bookstack | a root namespace that is not `App\`, controllers outside `Http/Controllers`, and an api file required inside a provider's prefixed group |
| monica | domain folders; actions run as `(new DestroyX)->execute($data)`; Laravel 11's `withRouting` |
| firefly-iii | the action array (`['uses' => 'Ctrl@method', 'as' => …]`) under `namespace` groups; repositories behind interfaces resolved from the container |
#### What the four new repositories exposed
Before they were cloned the Laravel reader was graded on a fixture written for it, and
it passed. On the first run over real applications:
- **koel yielded no routes at all.** Only `routes/web.php`, `api.php`, `console.php` and
`channels.php` were read, and koel keeps its routes in `api.base.php` and
`web.base.php`. Every PHP file under a `routes` directory is read now.
- **firefly-iii's 640 routes carried 5 handlers.** It writes every action as
`['uses' => 'Controller@method', 'as' => 'name']`, a form the reader did not know.
- **Three of the four served their API one prefix away from where it was reported.**
bookstack, monica and firefly-iii register `routes/api.php` under `/api` from a
provider or from `bootstrap/app.php`, so 336 routes were stored without it. A routes
file is registered under whatever loads it now. koel loads its files by a computed
name, which is not resolved, and its paths are as written.
- **Resource routes were invented.** `apiResource(…)->only([…])` emitted all the
actions regardless, every parameter was `{id}`, and koel's nested
`albums.songs` was stored as the path `/api/albums.songs`.
**Why firefly-iii reaches a model on fewer than half its routes.** Its controllers
receive their repositories from the container inside a middleware closure
(`$this->repository = app(BudgetRepositoryInterface::class)`), and act on route-bound
models they only pass along. Neither states a type a call can be resolved through, so
the chain stops one step short. That is reported as a stop, not as an endpoint that
touches nothing.
**koel's 38 unbound handlers are mostly real.** It registers
`apiResource('albums', AlbumController::class)` with no `only`, so Laravel registers
`store` and `destroy` routes for a controller that declares neither. The routes exist
and would fail when called; enola reports them unbound instead of dropping them.
## 1. Reproducibility
Each repository was indexed **three times** — once cold, twice warm — and the
receipt's `snapshot_id` and the SHA-256 of `facts.jsonl` were compared across all
three. Running cold then warm is the point: it tests that a cached run and a
from-scratch run agree, not merely that the same code path repeats itself.
> **95 of 95 repositories in this sweep produced a byte-identical `snapshot_id` and a
> byte-identical `facts.jsonl` across all three runs — 285 runs, 8,355,653 facts,
> zero drift.** `insights.json` is byte-stable on 90 of the 95; the five where it is
> not (cognee, superset, airflow, cognee-rs, spark) differed in the previous sweep
> too, and their facts do not. This is one sweep:
> the Dart/Flutter rows previously measured separately are folded in.
Two defects had to be fixed for that sentence to be true again, and both had the same
shape: **a snapshot must not depend on whether a previous snapshot exists.** A
markdown link was resolved by stat-ing the filesystem, so this repository's own docs
— which cite paths under its output directory — linked to nothing on a cold run and
to something on the next. And the output directory was ignored only at the repository
root, so a cluster config that snapshots subdirectories left an `.enola` in each and
enola indexed its own `llm_context.md` as a source document. Ten repositories in this
corpus carried such facts, `linux` and `wordpress` among them. Links now resolve
against the walked file set, and the output directory is ignored at any depth.
The 0.4.0 validation ran the whole sweep **twice**, forty minutes apart, and compared
the two: all 81 `facts.jsonl` were byte-identical between sweeps as well — 486 runs in
total. That check belongs to a release rather than to every sweep, so it is the one
number here carried forward. Three runs inside one sweep share a process lifetime and a warm page cache;
two sweeps do not, so this rules out a class of drift the in-sweep check cannot see.
What it does not cover is worth naming. `snapshot_id` hashes the fact stream, the
version and the config — **not** the receipt's `file_hashes` list, which records a
content hash per walked file and is what `enola check` and `plan` use to judge whether
a snapshot still describes the working tree. A discrepancy confined to that list would
leave every number above unchanged.
This is the property everything else rests on. `snapshot_id` is
`sha256(facts ‖ enola version ‖ config hash)`, not a UUID, so two runs on the same
commit are provably the same graph. That's what lets `compare_receipts` refuse to
diff snapshots that are not comparable instead of reporting churn as your change.
An ID a third party can re-derive is the point rather than a hygiene property: it is
what makes a graph a value you can keep and compare, instead of a state you are
currently in. See [SNAPSHOTS.md](SNAPSHOTS.md).
## 2. Delta precision — the ratchet
The claim is that enola reports **what your change did** and stays silent about
everything that was already wrong. That is testable directly: pin a baseline, put
the repository through four states, and see what each one is graded as.
For each repository below, files were **added, never edited**, so every revert is a
delete. The cycle case adds two new modules that import each other.
**The policy these runs enforced was `--fail-on=cycles`**, stated because it has to be:
`enola check` fails nothing unless a policy names it, so the FAIL column is what that
flag does, not what a bare run does. The column that matters for precision is the same
either way — the delta itself, and the fact that it is exactly one finding.
| Repository | Language | Pre-existing findings | No change | Benign addition | Injected cycle | Reverted |
|---|---|---|---|---|---|---|
| solidus | Ruby | 239 | PASS · +0 facts | PASS · +3 facts | **FAIL · 1 regression** | PASS · +0 |
| chatwoot | Ruby | 226 | PASS · +0 facts | PASS · +3 facts | **FAIL · 1 regression** | PASS · +0 |
| gauzy | TypeScript | 182 | PASS · +0 facts | PASS · +2 facts | **FAIL · 1 regression** | PASS · +0 |
| superset | Python | 132 | PASS · +0 facts | PASS · +2 facts | **FAIL · 1 regression** | PASS · +0 |
| cognee | Python | 119 | PASS · +0 facts | PASS · +2 facts | **FAIL · 1 regression** | PASS · +0 |
| drift | Dart | 100 | PASS · +0 facts | PASS · +2 facts | **FAIL · 1 regression** | PASS · +0 |
| jellyfin | C# | 99 | PASS · +0 facts | PASS · +3 facts | **FAIL · 1 regression** | PASS · +0 |
| lobsters | Ruby | 98 | PASS · +0 facts | PASS · +3 facts | **FAIL · 1 regression** | PASS · +0 |
| localsend | Dart | 96 | PASS · +0 facts | PASS · +2 facts | **FAIL · 1 regression** | PASS · +0 |
| gitea | Go | 92 | PASS · +0 facts | PASS · +2 facts | **FAIL · 1 regression** | PASS · +0 |
| eshop | C# | 80 | PASS · +0 facts | PASS · +3 facts | **FAIL · 1 regression** | PASS · +0 |
| excalidraw | TypeScript | 73 | PASS · +0 facts | PASS · +2 facts | **FAIL · 1 regression** | PASS · +0 |
| enola | Go | 70 | PASS · +0 facts | PASS · +2 facts | **FAIL · 1 regression** | PASS · +0 |
| crates-io | Rust | 67 | PASS · +0 facts | PASS · +2 facts | **FAIL · 1 regression** | PASS · +0 |
| gitbucket | Scala | 56 | PASS · +0 facts | PASS · +3 facts | **FAIL · 1 regression** | PASS · +0 |
| elk | TypeScript | 47 | PASS · +0 facts | PASS · +2 facts | **FAIL · 1 regression** | PASS · +0 |
| ngx-admin | TypeScript | 44 | PASS · +0 facts | PASS · +2 facts | **FAIL · 1 regression** | PASS · +0 |
| nowinandroid | Kotlin | 35 | PASS · +0 facts | PASS · +2 facts | **FAIL · 1 regression** | PASS · +0 |
| sveltekit-realworld | TypeScript | 3 | PASS · +0 facts | PASS · +2 facts | **FAIL · 1 regression** | PASS · +0 |
| cachet | PHP | 1 | PASS · +0 facts | PASS · +3 facts | **FAIL · 1 regression** | PASS · +0 |
Read the columns as four separate claims, all of which hold on all twenty repositories, 20 of 20:
- **No change → +0 facts, +0 edges, PASS.** Exactly zero on all twenty repositories.
That's what makes a PASS or FAIL on a real change something you can rely on.
- **Benign addition → PASS**, with the delta naming exactly the 2–3 facts added.
A new leaf module isn't a structural regression, so there's nothing to report.
- **Injected cycle → FAIL, exactly 1 regression** — out of **1,859 pre-existing
findings across these repositories**, up to 239 in a single one. None of them was
repeated. The ratchet holds.
- **Reverted → PASS again**, +0 facts. The verdict is a function of the tree, not
of history.
Ten languages, one behaviour. A regression is not detected by pattern-matching a
language; it is a cycle in the module graph, computed by Tarjan's SCC over the
resolved import edges, at confidence `1.0`.
**Swift is deliberately absent**, and the reason is a property of the language rather
than a limit of enola. A Swift module is a *declared* SPM target, so two added
directories that import each other form no edge and no cycle, verified: the modules
and the symbols appear, the cycle does not. Injecting one would mean editing
`Package.swift`, and then "reverted" would be a file restore rather than a delete,
which is exactly the property that makes the no-change column worth reading.
### What is eligible to fail at all
Across the corpus enola produced **12,027 findings**. Broken down:
| Explainer | Findings | Class |
|---|---|---|
| dead-methods | 2,847 | candidate |
| god-class | 1,649 | statistical outlier |
| hotspots | 1,327 | statistical outlier |
| **cycles** | **1,227** | **structural fact + heuristic — see below** |
| exported-surface | 1,102 | candidate |
| layers | 1,078 | heuristic |
| complexity-outliers | 1,062 | statistical outlier |
| query-loops | 769 | heuristic |
| dependency-depth | 603 | statistical outlier |
| domain | 351 | heuristic |
| entry-points | 12 | candidate |
**The total fell from 29,633 at v197, and 23,194 of that drop is one explainer.**
hotspots was roughly 80% of every finding enola produced, which meant the ranking of
one explainer decided what a reader saw first, and the other nine were buried
underneath it. It now reports at most its top 20 per repository, highest score first,
ties broken by name so the cut is deterministic. The outlier threshold already kept
the set small on most repositories, so the cap only bites where the volume was noise.
Two smaller corrections travelled with it. hotspots scores fan-in × fan-out, and
`has_method` edges — a type to the methods it declares — were being counted as calls
*out of* the type, so a large class read as a pinch point for being large: one
449-line importer whose body is 102 one-line delegations and exactly one call out
ranked in a monolith's top 20 as "it calls out to 104 others". And `dead-methods` is the newest
explainer and immediately the largest row at 2,847 — every entry a candidate, never a
verdict, scoped to surfaces whose callers the graph can see.
**None of these fail anything by default** — `enola check` names no explainer unless
you do. The number worth reading is how much of the corpus is even *eligible* to fail
at the `1.00` floor, whatever you name: only findings enola proves reach it, and every
statistical row above caps below it by construction.
**The cycles row is not all one thing, and the difference is instructive.** The
explainer emits a load-order cycle at confidence `1.0` and, separately, a *highly
coupled module cluster* at `0.4` — mutual references between directories in an
autoloaded codebase (Rails, say), where constants resolve lazily and there is no
load-order defect to break. Both carry `source: cycles`, so counting the explainer
overstates what `--fail-on=cycles` would do.
Counting the corpus by confidence rather than by explainer: **1,301 of the 12,027
findings sit at the `certain` level**, the only one that reaches the `1.00` floor.
So **10.8% could fail a build even with every explainer named**, and the other 89.2%
are reported and let you through however you configure it.
That share was 3.16% at v197 and 14.3% one extractor version ago, and it moves in
both directions for the same reason: it is a denominator. Capping hotspots removed
23,194 findings that could never have failed anything, which raised it; `dead-methods`
then added 2,847 candidates, which lowered it again. **The count that can fail is
1,301 either way** — it has not moved. The ratio is the design — the confidence floor keeps an estimate from
breaking a build even when someone asks it to, and the empty default keeps enola from
asking on your behalf.
## 3. Cross-repo resolution, misses included
enola reports both resolved and unresolved edge counts, so you know how many
dependents a service actually has.
### On the committed multi-repo fixtures
Eleven cross-language fixtures, each with a golden fact file the test suite asserts on:
| Fixture | Languages | Detected | Resolved | Unresolved | External | Edge confidence |
|---|---|---|---|---|---|---|
| ts_nest_multirepo | TypeScript ↔ TypeScript | 4 | 4 | 0 | 0 | verified |
| go_grpc_multirepo | Go ↔ Protobuf | 3 | 3 | 0 | 0 | verified |
| php_multirepo | PHP ↔ PHP | 2 | 2 | 0 | 0 | verified |
| py_fastapi_multirepo | Python ↔ TypeScript ↔ Go | 2 | 2 | 0 | 0 | — |
| py_grpc_multirepo | Python ↔ Protobuf | 1 | 1 | 0 | 0 | verified |
| multirepo | OpenAPI ↔ OpenAPI | 1 | 1 | 0 | 0 | verified |
| ts_express_multirepo | TypeScript ↔ JavaScript | 4 | 2 | **2** | 0 | verified |
| go_httpclient_multirepo | Go ↔ Go | 4 | 1 | **1** | 2 | probable |
| go_kafka_multirepo | Go ↔ Go (Kafka) | — | — | — | — | topic-matched |
| kotlin_swift_multirepo | Kotlin ↔ Swift | — | — | — | — | — |
| dart_multirepo | Dart ↔ Go | 3 | 3 | 0 | 0 | verified |
| **Total** | | **24** | **19** | **3** | **2** | |
The three unresolved edges are all deliberate, and each is a documented limit rather
than a bug: a path too generic to attribute (`/healthcheck`), a path no loaded server
serves, and a base URL injected from config with no literal binding.
`verified` versus `probable` is a real distinction the fact carries: an edge matched
through a declared contract (a `.proto` service, an OpenAPI `operationId`) is an
identity; an edge matched by comparing URL strings is a resemblance. See
[gRPC and OpenAPI](extraction/grpc-openapi.md).
### On a real public cluster
Nextcloud server plus two first-party apps, indexed into one graph
(101,673 + 1,812 + 5,458 facts):
```
service classification detected resolved unresolved
collectives coverage_gap 2 0 2
contacts isolated 0 0 0
server coverage_gap 13 0 13
```
Zero of thirteen resolved. The report doesn't disguise it as success: `server` is
classified **`coverage_gap`**, not `isolated`, and the output says so in words —
> 1 service is classified `coverage_gap`: no resolved outbound edges, but enola DID
> detect outbound call sites. Do not read these as isolated — they are the case where
> "depends on nothing" and "enola could not tell" look identical from the graph alone.
Nextcloud apps reach the server through in-process PHP APIs rather than cross-repo
HTTP, so there is little here for the linker to match. The point of including it is
that a report which only showed clusters where enola does well would not be evidence
of anything.
### On a .NET cluster
`bitwarden/server` (C#) plus `bitwarden/clients` (TypeScript) — two independent
repositories, different build systems, talking over HTTP. This is the shape the
other clusters lack: cluster 1 is in-process PHP, and the gRPC-web example is one
tree.
```
service classification detected resolved unresolved
bitwarden-clients connected 22 2 20
bitwarden-server coverage_gap 13 7 6
```
Before this corpus supported .NET, `bitwarden-server` read **`isolated` with 0
unresolved** — no `role=client` routes were emitted at all, so the linker had one
side of every edge and the server appeared to call nothing. `coverage_gap` with 6
unresolved is the honest replacement: enola found calls it cannot place.
Seven of thirteen resolve. The twenty unresolved on the client side are mostly
endpoints served by repositories not in this cluster.
### On a Dart/Flutter cluster
`ente-io/ente`'s Flutter client plus its Go server — a mobile app and the backend it
talks to, indexed as two repositories. A mobile client is the extreme case for
cross-repo linking: it serves nothing, imports nothing from the backend, and shares no
code with it, so an outbound call site is the *only* structural evidence the two belong
to one system.
```
service classification detected resolved unresolved
mobile connected 169 167 0
server isolated 0 0 0
```
Before, this read `coverage_gap: 168 detected / 0 resolved / 167 unresolved` — and the
missing side was **not** the Dart one. ente's server is written with **gin**, which the
Go extractor did not read, so the client's 168 call sites had nothing to match against.
Adding gin recovers 359 server routes (one per registration outside test files, exactly)
and 112 of the client's 117 distinct paths match one exactly.
`server` reads `isolated` rather than `coverage_gap` because it makes no outbound calls
of its own — a backend at the edge of the graph, which is the correct classification and
not a gap.
### On the example a reader can run
[`examples/cross-repo/`](../examples/cross-repo/) — one command, two Go services:
```
==> Routes the api service actually serves
(registered as "/orders/{id}" — stored at the composed runtime path)
"/api/v2/orders"
"/api/v2/orders/{id}"
service classification detected resolved unresolved
api isolated 0 0 0
web connected 3 2 1
```
`api/server.go` registers the bare `/orders/{id}` inside a function that receives a
subrouter mounted at `/api/v2` in `main`. Neither function contains the served path;
the fact does. The web service's call resolves *because* that prefix was composed
across the call boundary. The one unresolved call is a deliberately dynamic URL,
so the demonstration proves its own limit in the same run.
## 4. Scale
| | |
|---|---|
| Largest repository indexed | **Linux kernel** — 57,480 files, **2,068,619 facts**, 142.1s cold / 37.9s warm |
| Largest .NET | dotnet/runtime — 23,678 files, 537,421 facts, 54.7s / 27.7s |
| Largest Ruby | GitLab — 55,949 files, 551,664 facts, 37.4s / 23.0s |
| Largest Rust | rust-lang/rust — 37,669 files, 391,491 facts, 15.2s / 8.5s |
| Largest Scala | Spark — 5,749 files, 219,995 facts, 8.4s / 6.5s |
| Largest Go | Grafana — 11,379 files, 190,048 facts, 8.8s / 4.5s |
| Throughput | 1,700–40,000 facts/sec depending on language |
| Parse errors, all 95 repositories | **0** |
| Memory | peak heap per run is recorded by the sweep (`--memstats`) alongside time and hashes. The Linux kernel is the high-water mark at **6,455 MiB**; only five others exceed 1 GiB (GitLab 2,555, dotnet/runtime 2,049, dart-sdk 1,426, roslyn 1,367, rust-lang/rust 1,340). No repository required tuning on this machine |
Warm runs are 1.1×–27.0× faster than cold (over the 75 repositories whose cold run
exceeds 0.5s; below that the timing is noise), from the per-file content-hash cache
in `snapshot.meta.json`. These numbers establish that the graph the other four
sections rely on can actually be built on real code. enola isn't benchmarked on
speed as a competitive claim.
**The memory ratchet passes, and what it caught this time is worth recording.** Three
analyzers moved into enola at v268 and became defaults: `package-metrics`,
`dead-code` and `performance`. The first sweep after that breached a ceiling by six
times — `runtime` peaked at 13,130 MiB against 2,250 — and the harness is the only
reason anyone found out, because nothing about the analyzers' output was wrong.
Two measurements separated the cause from the noise. The first sweep ALSO reported
`roslyn` at 3,665s cold against 21.5s and a 30 GB peak, which was not a regression at
all: that sweep was run while a test suite and a linter were running on the same
machine, and `mem-ceilings.json` says in its own header to calibrate a ceiling
against the kind of run it grades. Re-run quiet, the timings came back and the memory
did not — which is what made the remaining figure a real finding rather than a
contended one.
The second was an A/B on rust-lang/rust, same binary, same repository, one explainer
at a time:
| explainer set | peak heap | wall |
|---|---|---|
| the nineteen that shipped before | 1,614 MiB | 27.2s |
| plus `package-metrics` | 1,535 MiB | 26.1s |
| plus `performance` | 1,535 MiB | 26.7s |
| plus `dead-code` | **8,798 MiB** | **51.0s** |
Two of the three were free. Profiling the third put all of it in one structure: a
reference index of `map[string]map[string]struct{}` holding **120,897,613 entries
over 1,625,276 relations**, 75 per relation, retaining 5 GiB. The multiplier is name
collision rather than volume — the index keys on folded short names, and
rust-lang/rust carries a UI test corpus declaring `Foo`, `T` and `Struct` in tens of
thousands of files, so 9,984 names held a thousand sources or more each.
The fix is that the index was answering a question nobody asked. Both of its readers
ask only whether *at least one* source satisfies a predicate, so it now stores a
count that saturates at two, the first source, and up to four sources with distinct
owners — which answers both EXACTLY rather than approximately, and the exactness is
what licenses throwing the rest away.
| | before | after |
|---|---|---|
| `runtime` peak (ceiling 2,250) | 13,130 MiB | **2,004 MiB** |
| `linux` peak (ceiling 8,000) | 6,725 MiB | **6,761 MiB** |
| rust-lang/rust peak | 10,435 MiB | **1,580 MiB** |
| rust-lang/rust wall | 51.6s | **27.4s** |
No ceiling was raised. The orphan set is byte-identical on four repositories checked
before and after (golf 1,399, dubbo 7,791, chatwoot 1,408, gmsh 12,194), which is
what makes it a performance fix rather than a behaviour change.
The lesson is the one the harness exists for: this cost was always there, and it was
invisible for as long as the analyzer shipped behind a licence that the open-source
benchmark never exercised. Making it a default is what measured it.
**Six extraction and store changes landed in this build, and what they moved is
allocation rather than wall clock.** None of them changes what is extracted, so none
bumped `cacheVersion`: every repository below carries a `facts.jsonl` hash identical
to the one the previous binary produced, and the route and framework counts in
section 5 moved by exactly one fact, in a repository whose own commit changed.
Allocated bytes is the metric this section leans on, because it reproduces: across
the controlled A/B pairs behind these figures it varied by under 0.05% between runs
of the same binary, while peak heap varies 23% to 28% run to run (`mem-ceilings.json`
says so in its own header) and wall clock varies with whatever else the machine is
doing.
| language | allocated, v268 | v271 | |
|---|---:|---:|---:|
| Scala | 446,985 MiB | 310,028 MiB | **-30.6%** |
| C | 94,430 MiB | 87,474 MiB | **-7.4%** |
| C++ | 2,364 MiB | 2,178 MiB | **-7.9%** |
| whole corpus | 755.7 GiB | 615.0 GiB | -18.6% |
The corpus row is the other three and almost nothing else: Scala accounts for 133.7 GiB
of the 140.7 GiB drop and C with C++ for 7.0 GiB. That is the honest shape of the
result, because those are the two extractors the work rewrote.
Scala was the outlier the sweep had never named. It parsed every file twice, once for
the declaration walk and again for the route and storage walks, and its import gates
copied each whole source to run a substring test. At 50.7 MiB allocated per parsed
file on Spark it sat about 100x above Java and Kotlin, which use the same parser
infrastructure and the same package index. Sharing one parse and matching on the bytes
took nine Scala repositories from 66.3s cold to 48.1s and cut their allocation by
30.6%, with a byte-identical `facts.jsonl` on every one. The C and C++ figure is a
second reading of the same mistake: the `#define` pre-pass split every source into a
string per line so that almost every line could be rejected on a leading `#`, which
measured 2.1x slower and 11.5x more allocation than scanning the bytes, over 3,000
kernel sources producing identical results.
One figure that is not in a table because it is not what the sweep records: the name
index is now a fact index rather than a slice per name, since 98.2% of the kernel's
1.9M names carry exactly one fact. Measured back to back on the same repository, that
is 1,421 MiB of steady heap against 1,343 and 1.9M fewer allocations. Steady heap is
what an MCP server holds for as long as it runs, so it outlives the snapshot the rest
of this section times.
**Two caveats, stated rather than hidden.** The corpus cold wall reads 757.3s against
658.7s, and most of that is not this work: Dart, C#, Ruby, Rust and PHP each moved 12%
to 21% in wall while their allocation stayed flat, which no change here can cause. Only
the Scala wall figure above is claimed, and it is claimed because a controlled
alternating-order A/B reproduced it. And the kernel's warm run reads 41.5s against the
previous sweep's 38.0s, which is machine state rather than code: measured back to back
four times on a quiet machine the same binary gives 38.3s to 39.1s. The sweep figure is
the one published, because a restricted re-measurement grades a different quantity than
a full sweep reaches after forty minutes of load, and `mem-ceilings.json` records what
conflating the two already cost once.
Every ceiling in `mem-ceilings.json` is unchanged and the ratchet passes: linux at
6,457 MiB against 8,000 and 443.9 mallocs per fact against 480, runtime at 2,094
against 2,250 and 663.9 against 740.
## 5. What the extractors see
Across the corpus enola extracted **36,817 routes** (28,732 server, 8,085 client) and
recognised **54 distinct frameworks** without configuration:
```
rails 7963 · wordpress 6668 · axios 2633 · angular 2170 · graphql 2020
openapi 1998 · grape 1948 · nestjs 1571 · laravel 1533 · aspnetcore 1192
play 923 · request-options 802 · chi 751 · spring 578 · symfony 574
fastapi 431 · resttemplate 423 · gin 390 · flask 326 · fetch 278
dart 173 · go_router 156 · axum 133 · grpc 132 · vue 131
auto_route 100 · utoipa 87 · graphql-ruby 81 · net/http 67 · nuxt 55
blazor 48 · openapi-fetch 46 · nextjs 45 · guzzle 44 · navigator 43
http-client 42 · httpclient 36 · net-http 32 · pekko-http 29 · faraday 23
requests 21 · http4s 21 · aiohttp 15 · sveltekit 13 · file-get-contents 12
express 11 · client-seam 10 · httpx 7 · hono 7 · gorilla/mux 5
retrofit 4 · urlsession 4 · django 4 · razorpages 2
```
**`laravel 1533` is new.** The four Laravel applications joined the corpus for this
run, and the reader that first met them found no routes in one and almost no handlers
in another: see [the Laravel rows](#php--laravel) for what that turned up.
**`angular 2170` is the fifth-largest framework and was zero.** It counts route facts
only — 612 page routes composed across files plus the client calls a component makes
— which is a small fraction of what the dialect reads: 12,174 classes carrying a
container role, 36,338 template references, 12,754 composition edges and 7,975
injection edges do not appear in a route tally at all. `axios` and `nestjs` grew for
the same reason the Angular rows exist: the corpus gained ten repositories, three of
which carry a TypeScript backend beside the Angular client.
**rails fell from 10,776 to 7,963, and the drop is the point.** The reader now honours
what a Rails route table actually declares: `resources :profiles, only: :show` is one
route, not seven, and a `scope constraints: { format: :html }` block is not a `/html`
path prefix. On rubygems.org that is 569 route facts down to 246 — the removed ones
include a `DELETE /api/v1/api_key` against a resource declared `only: %i[show create
update]`, and 200-odd paths that were reported one segment deeper than they are served.
A route count is not a score: an extractor that invents six verbs per resource scores
higher and is wrong more often.
The earlier growth still stands underneath it — routes used to be collected from the
root `config/routes.rb` alone, so every engine and plugin route file went unread, and
Grape had no extractor at all, leaving GitLab's entire v4 REST API invisible behind a
single `mount ::API::API` (`grape 1,948`).
Fact kinds: 5,947,210 symbols · 2,064,561 dependencies · 114,400 file refs ·
96,987 test refs · 82,770 modules · 36,817 routes · 7,150 associations ·
5,550 storage · 190 extraction · 18 intent. **`association` is new in 0.4.0** — a
model's declared `has_many`/`belongs_to` relations, which is what lets `endpoint`
walk from a URL to the tables behind it. Service nodes
are absent here by construction: the sweep indexes one repository at a time, and a
service node only exists in a multi-repo graph — those are section 3's subject.
**Scala's three route surfaces appear at `play 923`, `pekko-http 29` and
`http4s 21`**, and the shape of that split is the point rather than the totals. Play
declares a whole application's endpoints in one file, so one application supplies
923 of them; the other two are DSLs written per route tree, and the corpus holds one
real application for each beside the library that defines it. A library defines a
DSL and does not use one — which is why counting `pathPrefix` in a routing library
finds the directive's own definition and almost no endpoints.
`aspnetcore` at 1,192 is the fourth-largest framework, and `storage` is non-zero for
.NET at all — both were **zero** before this corpus was indexed for it. `blazor 48`
and `razorpages 2` count `@page` routes only; the bulk of Razor's contribution is
reference edges rather than routes, which is what the format is mostly used for.
Two rows are worth reading carefully rather than as a score. **django 4** counts
routes, not coverage: Saleor is GraphQL-first, so its REST surface really is that
small — 140 of its 144 routes carry the `graphql` tag — while its facts still carry
the Django tag too. And **`utoipa 87` is a row that did not exist one
sweep ago**. Two Rust services in the corpus declare part of their API through
`utoipa_axum`'s `routes!()` macro, which registers a handler without repeating its
path: the path is written in a `#[utoipa::path]` attribute on the handler. Reading
that attribute takes **crates.io from 8 served routes to 74** — 57 distinct paths,
against the 91 client calls its own frontend makes — and cognee-rs from 77 to 98. The
macro is still not expanded; it does not have to be. See [rust.md](extraction/rust.md).
What that surfaced is worth more than the count. On crates.io the `domain` explainer
was reporting **19 components as calling outbound endpoints** — an inventory of
third-party integrations that do not exist, because those endpoints are served by the
Rust half of the same repository and enola could not see the routes. The explainer
checks for exactly this and excludes a call the graph shows as self-served; it had
nothing to check against. With the routes visible all 19 are gone, and on a cluster
containing crates.io the calls resolve: 2 of 91 call sites resolved before, 90 of 91
after. A missing extractor does not only subtract facts — it manufactures findings,
and they read exactly like real ones.
A third belongs to .NET. OrchardCore contributes 82 routes where it declares 288
verb attributes, because 94% of its controllers are conventionally routed and 20 of
its registrations use a `{area}/{controller}/{action}` template that cannot be
resolved without each controller's area. That gap is counted and logged rather than
closed by guessing — see [dotnet.md](extraction/dotnet.md).
## 6. Agent A/B — does the agent ship the regression?
> Unlike sections 1–5, this one was **not** re-run for the current corpus: it needs
> real agent sessions rather than a script, so its numbers are carried forward from
> the earlier run. The fixture and the finding are unchanged; only the date differs.
The one A/B worth running is against **no tool**, on a task where the obvious
implementation is structurally wrong.
**Setup.** A small TypeScript service with three modules: `src/domain`, `src/store`
(imports domain), `src/api`. The task: *add `recentOrders(customerId)` to the domain
package and expose it from the API handler.* The natural implementation makes
`domain` import `store`, closing a module cycle. TypeScript compiles it without
complaint and every test still passes.
Three arms, three trials each, Claude Code headless (`claude -p`, Claude Code 2.1.205).
Each arm adds one thing to the one before it:
- **bare** — no MCP servers, no instruction, no hooks.
- **instruction** — enola's MCP server plus the exact instruction `enola install`
writes into the project's agent rules, which tells the agent to pin a baseline
before editing and diff afterwards.
- **loop** — the same, plus the session hooks `enola install --hooks` configures, so
the change is graded when the agent stops whether or not it remembered to ask.
Ground truth is identical for all three and does not trust the agent: the tree each
run leaves behind is graded against a baseline pinned before it started.
| Arm | Shipped the regression | Median turns | Median cost | Median wall |
|---|---|---|---|---|
| bare | **3 / 3** | 12 | $0.31 | 53s |
| instruction | **1 / 3** | 14 | $0.66 | 125s |
| loop | **0 / 3** | 24 | $1.12 | 252s |
The bare agent shipped a dependency cycle every time. With the hooks installed, none
of the three did, and the transcripts say why. From one of them, unprompted:
> The regression was `src/domain ↔ src/store`: my first attempt made the domain
> `import { listOrders }` from the store, which closed a loop with the store's
> pre-existing `import type { Order }` from the domain. I removed the offending edge.
The loop worked as designed: the agent introduced the cycle, the Stop hook graded
the session and handed back the verdict, and the agent corrected itself before
reporting done. It took roughly double the turns and 3.6× the money of the bare
run: the cost of catching and fixing the regression, against the bare run's
$0.31, which bought a tree with a dependency cycle already in it.
**Read this before quoting the table.**
- **Nine runs is not a statistic.** 3/3 versus 0/3 is a real gap and the ordering is
monotonic, but the difference between the instruction arm's 1/3 and the loop arm's
0/3 is one run. Treat this as a demonstration that the mechanism works end to end,
not as a measured effect size.
- **No arm ever called `diff_snapshot`.** Every enola-equipped run used exactly one
tool, `generate_snapshot`. The loop arm did not succeed because the agent asked
enola the right question. It succeeded because something asked on its behalf when
it stopped. That is the whole argument for the hook over the instruction.
- **The cost column is not enola's price.** It compares an agent that checked its work
against one that did not: the bare arm is cheap because it stopped early and shipped
the cycle three times out of three. The arm that would isolate what enola costs does
not exist here: an agent told to establish the same property *without* a graph,
re-deriving the module structure from source on every task. Do not read $0.31 → $1.12
as the overhead of adding enola to a run that was otherwise identical.
- **The instruction arm improved on bare anyway** (1/3 vs 3/3), which is worth being
honest about: having a structural map in context appears to help even when the
agent never queries the delta. With three trials, that could equally be noise.
- **One trap, one language, one model.** A deliberately subtle module-level cycle in
TypeScript.
## What this does not measure
Stated so the numbers above are not read as more than they are.
- **A task-level retrieval A/B.** The real-session measurement above records the
workload that occurred; it does not run the same retrieval tasks with and without
enola. Section 6's A/B cost column is a third figure: it compares checking against
not checking, not one retrieval path against another.
- **The session hooks in interactive use.** Section 6 exercises them headlessly, under
`claude -p`. The configuration is the same either way, but interactive sessions were
not measured.
- **Swift in the ratchet.** Measured in every other section; excluded from section 2
only, because a Swift module is a declared SPM target and no additive cycle
injection exists. Stated above rather than silently omitted.
- **Scala has no cross-repo cluster.** It is measured in sections 1 and 2 across
nine repositories, but section 3 pairs a backend with its own clients and no
Scala pair exists in the corpus, so Scala contributes nothing to the coverage
numbers. Its client-side extraction (sttp, Play WS) is exercised by unit tests
and the golden fixture only.
- **Kotlin and Swift at *large* scale.** Both now have a real public repository
(Google's reference Android app; a SwiftUI application), but at 312 and 382 parsed
files they are the small end of the corpus. The framework constructs are exercised
on production code; the scale claims are not.
- **Vue and Svelte route surfaces are small.** Nuxt and SvelteKit are measured on real
applications, but 56 and 13 routes respectively, enough to show file-based routing
works, not enough to characterise it.
- **Recall of cross-repo edges.** Section 3 reports what enola detected and what it
resolved. It does not report edges enola never detected at all: that would need a
hand-labelled ground truth for each cluster, which does not exist here.
- **Comparisons with other tools.** Not measured, not estimated, not implied.
## Reproducing
```bash
# one repository, three times — the reproducibility check in miniature
enola --generate /path/to/repo && cp .enola/receipt.json /tmp/r1.json
enola --generate /path/to/repo && cp .enola/receipt.json /tmp/r2.json
diff <(jq -r .snapshot_id /tmp/r1.json) <(jq -r .snapshot_id /tmp/r2.json) && echo reproducible
# the ratchet, on your own repository
enola baseline pin . # freeze the architecture
# …make a change…
enola check --fail-on=cycles . # exit 1 on a NEW cycle; a bare `check` reports
# everything and exits 0, so name what should fail
# cross-repo coverage, misses included
enola coverage cluster.yaml
```
> **Read the `enola: using config …` line each command prints, and check it is the
> config you meant.** A config's `extractors:` list *replaces* the built-in default
> rather than merging with it, so the wrong one does not fail — it quietly analyses
> something other than what you asked for. enola looks in the working directory first
> and falls back to the directory holding the binary.
---
Per-language extraction detail: **[docs/extraction/](extraction/README.md)** ·
What the words mean: **[docs/GLOSSARY.md](GLOSSARY.md)** ·
Commands and flags: **[docs/CLI.md](CLI.md)** ·
Why the graph is a snapshot: **[docs/SNAPSHOTS.md](SNAPSHOTS.md)** ·
What the findings are for: **[docs/EXPLAINERS.md](EXPLAINERS.md)** ·
How the engine works: **[ARCHITECTURE.md](../ARCHITECTURE.md)**