Python calls into a package's own __init__.py now resolve, and the dashboard opens with no snapshot
A gap in Python call resolution for symbols a package defines in its own __init__.py is fixed, and the dashboard now opens even before a snapshot exists, with clearer findings and dependency graphs.
- FixedCalls to a symbol a Python package defines in its own
__init__.py, rather than re-exporting from elsewhere, now resolve instead of dangling. - AddedThe dashboard now opens even with no snapshot yet generated, showing the exact command to run, and a Refresh control loads newer snapshots without disrupting the graph you're inspecting.
- FixedFindings are prioritized, lifetime usage is separated from diagnostics, and confidence labels and suggested actions are clearer.
- FixedThe dependency graph condenses cycles into clusters, renders edges more clearly, and gets a clear-selection control; browser launching on macOS is fixed and the docs are updated.
Action: re-pin baselines taken on a Python repository that uses this pattern — extraction caches from earlier releases are invalidated, so the first snapshot after upgrading re-extracts.
Implementation notes and pull requests
The re-export index mapped a package directory to the names its __init__.py imports from elsewhere, but not the names it defines itself. Both gaps produce the same dangling edge: from pkg import helper emits the target pkg.helper, whose prefix names a directory rather than a module, so absolute resolution failed and the call bound to nothing. Local definitions are now registered after the re-export pass and unconditionally — a name a package both defines and imports is shadowed by the local definition at runtime, which is the correct binding rather than an ambiguity to drop. PR #286.
Dashboard: snapshot loading now stays scoped to the selected repository unless an explicit cluster configuration is supplied, and graph data, snapshot metadata, and receipts are read from one consistent publication so a failed refresh preserves the page instead of blanking it. Findings are re-ordered by priority, lifetime-usage counts are split out from diagnostics, and confidence labels and suggested actions are clarified. The dependency graph condenses cycles into clusters, improves edge rendering, and adds a clear-selection control; browser launching on macOS is fixed, and dashboard and CLI documentation are updated. PR #283.
Policy-as-code constraints now cover Go structs, and a silent gap in intent checking is reported
A new policy-as-code example demonstrates GDPR and PCI-DSS constraints end to end; require_defines now matches Go/Rust/C++/C# structs as well as classes, and a stale-citation check that was silently skipping unmeasured repositories now says so.
- AddedA new
examples/policy-as-codeexample walks through declaring GDPR and PCI-DSS cardholder-data constraints and checking a codebase against them. - Fixed
require_definesnow matches struct-kind types (Go, Rust, C++, C#) as well as classes, so a rule like "every store definesErase" no longer verdicts nothing on a Go codebase. - FixedA page whose anchors name a repository the current snapshot doesn't carry now raises an "Anchors not checked" notice instead of silently passing as if nothing were wrong.
- Fixed
constraints lintnow resolves component membership through compiled policy pages, matching what the constraint check itself resolves. - FixedA yarn.lock dependency descriptor that itself contains an
@(an npm alias or a git+ssh URL) now resolves correctly, so a lockfile-pinned dependency is no longer reported as unpinned.
Action: re-pin baselines on projects with yarn.lock aliases or git-protocol dependency ranges — extraction caches from earlier releases are invalidated, so the first snapshot after upgrading re-extracts.
Implementation notes and pull requests
New example: examples/policy-as-code declares GDPR personal-data and PCI-DSS cardholder-data policies over a small checkout/gateway/vault codebase, wired through governed_by and require_governed constraints. PR #281.
Class-kind now means class or struct, since the owner of a method isn't spelled the same way in every language: Ruby, Python, TypeScript, Java, Kotlin and PHP write class; Go, Rust, C++ and C# write struct. While the gate admitted class alone, a rule written over Go structs resolved its members, verdicted nothing, and raised no advisory — vacuous compliance. Separately, a page whose anchors name a repository absent from the snapshot now raises one 0.4 anchors not checked notice per page rather than looking like a page whose citations all held; governed_by and require_governed are unaffected since with one repository loaded they drop the anchor's label rather than comparing it. constraints lint also now resolves a governed_by component through the compiled policy pages instead of a store that excluded them, matching the explainer. PR #282.
The name/range separator in yarn.lock parsing was read as the last @ in the descriptor, so an npm alias such as @typescript/native@npm:typescript@7.0.2 was misparsed and matched no manifest entry, reporting a dependency the lockfile pins to one version as unpinned. cacheVersion moves to v265. PR #282.
PyPI wheels now install cleanly on RHEL and Rocky Linux 8
pip install enola failed on RHEL and Rocky Linux 8 with "no matching distribution found"; Linux wheels now carry an extra tag so older pip versions on those distros can see them.
- FixedLinux PyPI wheels now carry both the PEP 600 tag and the older
manylinux2014alias, so pip 20.2.4 as shipped on RHEL/Rocky Linux 8 can find and install them.
Action: if pip install enola failed on RHEL or Rocky Linux 8, upgrading to 0.4.15 resolves it; 0.4.14's wheel cannot be fixed in place since PyPI won't accept a replacement file for a version it already has.
Implementation notes and pull requests
Installing 0.4.14 on Rocky Linux 8 failed with "no matching distribution found" despite its glibc 2.28 sitting well above the 2.17 the wheel actually needs: pip only learned the PEP 600 manylinux_<major>_<minor> tag spelling in 20.3, and RHEL and Rocky 8 ship pip 20.2.4, so the wheel that could run there was the one their pip couldn't see. Upgrading pip inside the container installed the identical file, which is what identified the cause. Linux legs now emit a compressed tag set — one file carrying both spellings in its filename and a Tag line for each in WHEEL, the same thing auditwheel does for the same reason. Verified by installing a dual-tagged wheel with stock pip 20.2.4 on Rocky 8. PR #280.
Enola ships on PyPI as a prebuilt wheel
pip install enola now works directly, with prebuilt wheels for Linux, macOS, and Windows published automatically from tagged releases; the installer also adds support for opencode.
- AddedEnola now publishes to PyPI as prebuilt wheels for Linux (x86_64/aarch64), macOS, and Windows —
pip install enola— built automatically from tagged GitHub releases. - AddedThe installer supports opencode as a target, writing its plugin hook and a strictly-validated MCP config entry.
- FixedCodex installation instructions in the README described the wrong MCP server setup.
Action: none — pip install enola is a new, optional install path alongside the existing ones. RHEL/Rocky Linux 8 users hitting "no matching distribution found" should install 0.4.15 instead; see below.
Implementation notes and pull requests
Linux wheels build inside manylinux_2_28 and are tagged manylinux_2_17, so the resulting binary requires only glibc 2.17 instead of whatever a generic Ubuntu runner links against — reaching RHEL and Rocky 8, Amazon Linux 2, Ubuntu 20.04, and Debian 11 that a glibc-2.34 build would exclude. The Go toolchain is mounted into the container from the runner rather than downloaded, so the compiler stays exactly what go.mod pins. Publishing runs only on a tag push through PyPI Trusted Publishing, downstream of and gated on the GitHub release succeeding, so a PyPI failure can't block or unpublish it; a workflow_dispatch path rehearses the full build against TestPyPI without touching GitHub releases or production PyPI. PR #279.
opencode reads none of the files the other install targets write. Its hooks are registered as a plugin, since opencode has no hook configuration of that shape, and its MCP entry is identified by shape rather than a marker field — opencode validates its config strictly, so enola writes no unknown keys and never touches an entry it didn't write. PR #278.
Documentation: corrected the Codex installation instructions in README.md, which described the wrong global MCP server setup. PR #277.
A new explainer measures what importing your Python package actually costs
A new import-closure explainer reports what importing a Python package executes, several Python and C++ call-resolution bugs are fixed, and three Go extractor passes get faster.
- AddedA new
import-closureexplainer reports whatimport yourpackageactually executes in Python, including the package__init__.pyfiles no import statement names but Python runs anyway, and flags the ones that dominate the closure. - FixedC++ call resolution now uses include visibility, qualified names, unique project-wide matches, and explicit receiver types to resolve more calls instead of relying on speculative matches.
- FixedPython absolute imports resolve to the exact module they name rather than the enclosing package directory, and forty-two standard-library modules missing from Enola's own list no longer misreport as third-party dependencies.
- PerformanceThree Go extractor passes now match tree-sitter queries directly and share a single pass instead of each walking the syntax tree separately.
Action: re-pin baselines taken on a C++ or Python repository — extraction caches from earlier releases are invalidated, so the first snapshot after upgrading re-extracts.
Implementation notes and pull requests
C++ calls on explicitly typed parameter and local-variable receivers now resolve to a uniquely declared method on that type; auto-deduced, template-wrapped, unknown, and ambiguous receivers stay unresolved rather than guessed, and a receiver call binds only when the method actually exists. cacheVersion moves to v260. PR #273.
A Python absolute import now binds to the module it names rather than the package directory that also matches one segment shorter, and a same-package absolute import resolves to its sibling file instead of collapsing to the shared parent directory; inheritance bound through a relative import, latent until now, also resolves. Import facts carry Props["deferred"]=true when the import does not run at module-import time — a function- or class-body import, or an if TYPE_CHECKING: block — distinguishing what's actually on the import path from what's merely visible. The Python stdlib set is completed against sys.stdlib_module_names: forty-two modules were missing, including atexit, binascii, colorsys, optparse, and curses, and a stdlib name absent from that set was classified as a third-party dependency. The new import-closure explainer reads a graph built at file granularity from these same dependency facts, because the module graph other explainers use resolves both ends of an edge up to the enclosing package and can't say which modules beneath one were paid for; per top-level package it reports modules loaded, share of the package, depth, and third-party packages on the path, and at severity 0.7 the package __init__.py files that dominate the closure — the barrel worth splitting. cacheVersion moves to v264. PR #274.
Three recently added Go extractor passes each walked a syntax tree to find a handful of nodes; go-tree-sitter allocates a heap object for every node it returns, so each pass paid an allocation for every node stepped over on the way to the few it wanted. The three passes now share a single match of the tree instead of walking it three times. Extractor output is unchanged — the corpus sweep is byte-identical in facts on every comparable repository — so no cacheVersion moves. PR #275.
GraphQL clients and servers now connect through the same route
Enola now reads GraphQL across TypeScript/JavaScript, Vue/Nuxt, Ruby, and Hasura, matching client operations to the server root fields they call and flagging schema operations nothing calls.
- AddedGraphQL extraction across a wide range of servers, including Apollo Server, GraphQL Yoga, Mercurius, NestJS GraphQL, TypeGraphQL, Nexus, Pothos, graphql-ruby, and Hasura metadata.
- AddedGraphQL extraction across clients, including Apollo Client, Relay, urql, graphql-request, Nuxt Apollo, and plain
fetch-based requests. - AddedClient operations and server root fields now share a canonical route name such as
Query.booksorMutation.addBook, so Enola can connect a client to the schema it consumes and report GraphQL operations nothing calls.
Action: none.
Implementation notes and pull requests
Framework-aware GraphQL extraction spans TypeScript/JavaScript, Vue/Nuxt, Ruby, Hasura, and standalone .graphql/.gql documents. Server coverage includes Apollo Server, GraphQL Yoga, Mercurius, express-graphql, graphql-http, GraphQL Tools and GraphQL.js's buildSchema, NestJS GraphQL, TypeGraphQL, Nexus, Pothos, graphql-ruby, Hasura metadata, and schema-first documents with server provenance, covering Query, Mutation, and Subscription root fields including SDL type extensions and common code-first aliases. Client coverage includes Apollo Client and gql tags, Nuxt Apollo inside <script> and <script setup>, Relay graphql tags, urql, graphql-request, plain fetch requests, standalone operation documents, and Ruby operation strings, including anonymous operations, aliases, directives, fragments, variables, and subscriptions. Client operations and server root fields resolve to a canonical route carrying type=graphql, a role of client or server, a registered GraphQL source, and a root-field name; the cross-repository signal matches client operations to server root fields on that exact name and contributes GraphQL coverage and consumption verdicts without mixing GraphQL routes into HTTP matching. Validated against NestJS's official GraphQL example, GraphQL.js, Nhost, and Relay patterns alongside framework-specific unit and regression fixtures; the full Go test suite, go vet, gofmt, and golangci-lint pass. Server coverage for JVM, Python, .NET, PHP, and Go GraphQL frameworks is a known follow-up. PR #272.
A Vue component's template is code, and enola now reads it
Vue and Nuxt extraction no longer stops at the <script> block: a component or handler used only from a template no longer reads as an orphan.
- AddedVue and Nuxt template interpolations and directive values resolve against the SFC's own declarations and component tags, so references from a template become
callsedges seen bytraverse,find_path,impact_analysis, and orphan analysis. - Added
<script setup>compiler macros (defineProps,defineEmits,defineSlots,defineModel,defineExpose,defineOptions) are recorded as the component's public surface. - AddedNuxt file-based routing covers more page extensions and drops route-group and named-view/client/server suffixes from the URL; literal Vue Router records compose into page routes with
handled_bycomponent edges. - ChangedExtraction caches from earlier releases are invalidated for Vue and Nuxt repositories, so the first snapshot after upgrading re-extracts.
Action: re-pin baselines taken on a Vue or Nuxt repository — the new template edges can resolve orphan findings and surface coupling that was previously invisible.
Implementation notes and pull requests
A Vue Single File Component's template is where a handler is wired to a button and a child component is placed, so a component used only from a template read as an orphan and a @click handler read as dead. Interpolations and directive values are now resolved against the SFC's own declarations, and component tags through default imports, named imports, local aliases, and unambiguous Nuxt components/ auto-import conventions; resolution is narrow where it cannot be sure, so HTML text, native tags and CSS tokens are not read as code, and a tag two Nuxt layers could both supply stays unresolved rather than binding to whichever was walked first. <script setup> macros land in vue_macros with a boolean per macro; statically declared prop, emit, slot, model, and exposed names land in the matching vue_*_names lists, with generic declaration text kept in vue_contract_types, and macro-looking text inside a comment or string is ignored. Nuxt file-based routes now cover every supported page extension rather than .vue alone, omit route-group directories from the URL, and drop named-view/client/server suffixes; Vue Router createRouter({ routes }) records with literal paths emit page routes, nested children compose onto their parent, and a statically or lazily imported component becomes a handled_by edge, while the configuration file keeps its router_config marker fact. A Nuxt auto-imported useXxx() call rebinds to its unique exported declaration under composables/, left unresolved when two layers export the name. PR #270.
cacheVersion moves to v258: every Vue and Nuxt repository gains facts and existing ones change shape, so extraction caches from earlier releases are invalidated and the first snapshot after upgrading re-extracts. PR #271.
Facts and evidence get stable ids, and the file formats are documented
Every fact and every piece of insight evidence can now carry a stable id computed at write time, two findings that cited names nothing in the snapshot carried are fixed, and the facts/insights/receipt file formats have a written reference.
- AddedFacts and insight-evidence citations can carry a stable id, computed when
facts.jsonlandinsights.jsonare written rather than stored on the fact itself. - FixedTwo findings cited names no fact actually carried; both now cite the real fact and its location.
- FixedThe served
insights.jsonemittednullwhere the written file emitted[]. - AddedA new
docs/schema/reference documents the facts, insights, and receipt file formats, plus anINTEGRATING.mdguide for building on Enola's output.
Implementation notes and pull requests
Ids are computed when facts.jsonl is written and never stored on a Fact: the struct has 418 dependents, the serializer has 3, and an id no internal reader can see cannot change an internal answer. A relation carries target_id only when its target name resolves to exactly one fact. Also corrects three documented claims the emitted data contradicted: repo is set on every fact including single-repo snapshots, declares points from the declared fact to its module, and the identity triple is a convention rather than a uniqueness guarantee. PR #267.
A citation that resolves to nothing is not a failure, so fact_id is omitted rather than forced: some findings are about absence, such as a route naming a handler not defined here, and that is the finding being true. Two findings previously cited names nothing carried: an outbound-integrations finding cited a display label with the HTTP method glued onto the path, and a layer-violation finding cited its import edge without the file it came from, so it could not be told apart from the same import elsewhere. Both now cite the fact and its location. PR #268.
Documentation: docs/schema/ documents the facts, insights, and receipt formats against what the code actually emits, correcting comments and doc claims in internal/facts/model.go that no longer matched the wire format. INTEGRATING.md is a new guide for consuming Enola's output directly. PR #265, PR #269.
The dashboard runs in the background, and changes route to who owns what they touch
enola dashboard now starts detached by default, and a new --reviewers flag routes a change to the owner of what it touched using the import graph rather than just the commit log.
- Added
enola dashboardstarts detached in the background by default and addsdashboard statusanddashboard stopto manage it;--generate,--refresh, andcheck --writeprint a one-line hint pointing at it. - Added
--reviewersreports, per module a change touched, who owns it and whether the author is a stranger to something they own an import of. - FixedA repository-scoped fact's directory label was read as a file the change touched, wrongly opening routing with a note about the root module on changes that only moved coverage counters.
- FixedA wrapper binary built on Enola reported "dev" in
doctor, its SARIF driver, and its dashboard instance record instead of its own version. - ChangedDocumentation reorganized across the CLI, constraints, clusters, providers, intent, and glossary references, with new doc-lint checks enforcing structure.
Action: enola dashboard now returns immediately instead of blocking; pass --foreground if you relied on the old behavior.
Implementation notes and pull requests
dashboard gains status/stop subflags and --foreground, detaching by re-executing the same binary in the background. pkg/cli.ShowDashboardHint is a single shared hint builder used by both --generate/--refresh and check --write so the wording can't drift between them, suppressed in CI via ENOLA_NO_PROMPTS/TTY detection. The Activity tab reorganizes active sessions above collapsed runtime details, and the extraction-quality panel adds a prefilled "Report extraction issue" link when parse errors are present. Follow-up work distinguishes normal shutdowns in server error handling and adds unit tests for shutdown and dashboard instance-tracking behavior. PR #260, PR #263.
--reviewers pairs a module's owner against its importers using the major-minor-dependency relationship from Bird, Nagappan, Murphy, Gall and Devanbu, "Don't Touch My Code!" (ESEC/FSE 2011), which needs the import graph rather than just the commit log to spot. It is never graded and not part of the snapshot: ownership is read from git history, so it moves while the code stands still, and a history is per-machine, so the same commit can grade differently on a laptop holding a local record than in CI. Off by default; without the flag no author name is read. PR #261.
An extraction fact carries a repository's directory name in File rather than a path, and changedFiles handed it to callers that read File as something somebody edited, so routing opened with a line about the root module on any change that moved an extractor's coverage counters. Filtered in changedFiles rather than at the source, since File is part of a fact's identity in every snapshot and diff and AttachGuidance reads the same list. PR #262.
pkg/command read internal/version, which only Enola's own release stamps set, so a wrapper binary reported "dev" in doctor, the SARIF driver it uploads under, and its dashboard instance record simultaneously. cli.Binary carries the version now, and whether a binary can upgrade itself is derived from the subcommands it dispatches for itself rather than a new field. PR #264.
Documentation reorganized across README.md, docs/CLI.md, docs/CLUSTERS.md, docs/CONSTRAINTS.md, docs/FIRST-CHANGE.md, docs/BLIND-SPOTS.md, docs/EXPLAINERS.md, docs/GLOSSARY.md, docs/INTENT.md, and docs/PROVIDERS.md, with new internal/docslint tests enforcing command and count consistency. PR #258, PR #259.
Declared dependencies join the constraint vocabulary, and every excuse gets counted
A new rule can require every external dependency to be pinned to a version, and enola constraints ledger reports how much of the declared architecture is being excused rather than obeyed.
- AddedA
dependencymember kind and a shippedsupply-chainrecipe can require every declared external dependency to be pinned to a version. - Added
enola constraints ledgerreports, across the whole rule set, what share of breaches are excused, the oldest excuse nobody has revisited, and which suppressions or exemptions matched nothing in the current snapshot.
Implementation notes and pull requests
supply-chain binds to nothing the repository has to name: its role selects kind: dependency with where: {type: package, pinned: false} over the package facts the manifest extractor measures, and the law is a forbid_fact over that selection — no new rule form, since "this set must be empty" already existed. A repository with no manifest facts gets a loud 1.0 "selector cannot be evaluated" finding rather than silent compliance. cacheVersion moves to v257. PR #257.
constraints ledger reads the same signatures every suppression and exemption already required — owner, reason, date — and turns them into a ratio instead of a pile of individually reasonable-looking exceptions. A rule most of whose breaches carry a signature is a rule to reconsider; an excuse untouched for months is a reason that has likely stopped being true; and a suppression that matched nothing in the current snapshot is marked inline and counted on the summary line, giving the coverage census's existing unused suppression and exemption matching nothing facts a denominator. PR #257.
Built-in providers respect what you told the engine to ignore, and layer rules stop flagging wiring code
A built-in fact provider now skips files your ignore rules already excluded instead of building facts for them and throwing the result away, and the layers explainer adds a neutral classification for dependency-injection and wiring code that was never meant to sit in a dependency order.
- PerformanceA built-in fact provider now receives the engine's ignore-glob decisions and skips an excluded file before reading it, instead of building the fact and discarding it afterward. A Rails monolith snapshot dropped from 440s to 193s.
- AddedLayer definitions gain a neutral classification for code that is classified but never ordered, such as dependency-injection wiring — one wiring package had been responsible for most of a repository's reported layer violations.
- AddedNuxt, SvelteKit, and PHP layer taxonomies.
- FixedThe Android clean-architecture taxonomy now orders data below domain, matching Android's own documented guidance; the previous ordering flagged legitimate domain-to-repository calls as violations.
Action: re-pin baselines if your project uses the layers explainer against Android, Nuxt, SvelteKit, or PHP code — violation counts will change.
Implementation notes and pull requests
The Rubydex provider's remaining cost after the previous release's walk fix was work done only to be thrown away: on a Rails monolith it built 2,155,664 facts and the repository's own ignore globs rejected 1,520,163 of them, 71%. Because a built-in provider runs in the engine's own address space, Collect now takes the engine's Ignored set directly, skipping an excluded document before its definitions are read and an excluded reference before its fact is built; the surviving 1,800,829 facts are byte-for-byte identical to before. Indexing stays untouched — vendored gems still feed the indexer, which is what lets a workspace constant resolve outside it — only emission is filtered. PR #255.
A Hilt di package or a Spring @Configuration package is referenced by, and references, every layer it wires together — that is its whole job — so any level it is given turns half of those edges into a violation; giving one Android taxonomy's di package a level had produced 61 of thingsboard's 75 findings and all 7 of dubbo's. The new Neutral flag classifies such packages without ordering them, applied to Android's di, iOS's config, Nuxt's server and wiring directories, and PHP's listeners, subscribers, and service providers. The Android layout itself now places data below domain: nowinandroid's own architecture guide states the data layer sits at the bottom, and every use case in it imports a repository, so the previous ordering had reported three of those imports as violations on Google's own reference application. PR #256.
A Rubydex reference could not stop referencing itself
The Rubydex Ruby fact provider could hang indefinitely on certain code shapes. A single self-referencing constant in a real Rails monolith reproduced it every time.
- FixedRubydex's declaration-path walk could loop forever when a reference's end column, plus its separator, equaled its own start column, making the reference its own predecessor. A candidate now qualifies only when it is a different reference whose end line matches the line being walked.
Action: a Ruby snapshot that previously hung or timed out should be retried.
Implementation notes and pull requests
The Rubydex provider could stop returning entirely: on a Rails monolith it was killed at 26 minutes holding 6.7GB with nothing written, on a tree the engine itself indexes in 24 seconds, and a second Rails application hung the same way. One reference caused it on that monolith — <LibDDWAF> in the libddwaf gem, spanning six lines — because the path walk compared a candidate's end column plus separator against the current segment's start column alone, with no check that the candidate was a different reference on the line being walked. The regression test constructs the reference shape directly rather than depending on a workspace, and fails against the prior code by never returning within its deadline; facts are byte-for-byte identical on repositories where both versions complete. The graph underneath was never the slow part: Rubydex indexes the same monolith in 13.5 seconds through its own CLI, and the fixed provider's index and resolve phases cost 9 seconds. PR #254.
Detection stops re-walking the tree, and constraint verdicts speak CI's language
Every extractor's file detection now reads from the same walked file list the engine already built, instead of re-scanning the tree with its own depth limit, recovering files that previously sat past an old scan boundary. The constraints program also gained CI-native output formats and richer explain results.
- FixedLanguage detection no longer re-walks the tree with a per-extractor bounded scan; it now reads membership over the file list the engine already walked, so a marker or source file counts wherever in the tree it sits.
- AddedA vendored-candidates explainer reports directories that look like an in-tree copy of another project — a licensed subdirectory under a conventional dependency folder — informational only and never gating.
- Fixed
constraints initandconstraints explainno longer report a successful run as a failure. Both used to return without exiting, so the outer argument parser mistook the completed run for an unknown command. - AddedThe constraints program writes SARIF and CI annotations alongside its existing text and JSON formats,
constraints explainreports the blast radius of moving a file out of its part, and every verdict now carries a "could not see" line naming what the provider census could not resolve. - ChangedDocumentation reorganized, including a dedicated Rails guide and a repository
CHANGELOG.mdmirroring this page.
Action: detection behavior changed across every language — regenerate snapshots and re-pin baselines.
Implementation notes and pull requests
Detection used to be a bounded re-walk inside every extractor, and every bound was a cliff: dotnet/runtime carries 3,270 C/C++ sources with none inside the three levels the C++ detector scanned, so the language was absent from the graph entirely. plugin.FileListDetector answers instead from the names the engine's own walk already collected, pruning only node_modules, vendor, testdata, and dot-directories — shorter than the engine's configurable ignore globs, since this decides what a language is spelled by rather than what gets indexed. Each migrated extractor keeps exactly one decision, its file-name predicate, so its bounded Detect and its unbounded walk cannot drift apart. cacheVersion moves to v253. PR #250.
Both constraints init and constraints explain returned on success rather than exiting, so main's flag loop re-read constraints as an unknown argument and printed "did you mean constraints?" before exiting 1 on a run that had already produced a correct report. The exit now happens at the dispatch site, where every other case in that switch already exits; lint and mine are unaffected. PR #251.
Eight additions to the constraints program follow from one property: the verdict has four writers — text, json, sarif, annotations — and all four read the same computed verdict, so nothing is recomputed per writer. SARIF carries one rule per declared rule id; annotations place every positioned finding on its file and line. constraints explain now answers incoming edges and reports the blast radius of moving a file out of its part. Provider facts are cached, keyed by content for per-file providers and by library version, file-set digest, and lockfile for the built-in Rubydex provider, with the receipt reporting how much was reused. since and growth read git blame per witness file, cached by blob hash. Two providers spelling the same receiver differently now produce one edge instead of two. Every verdict carries one "could not see" line and a census of what each provider contributed. A 281-case benchmark suite passes at 281 with no case regressed; the provider cache was measured against a Rails monolith of roughly 1.7 million facts, where providers previously cost 249 of a 292-second run. cacheVersion moves to v256. PR #252.
Documentation reorganized: a repository-root CHANGELOG.md now mirrors this page at the resolution a reader of the repository needs, and constraints, provider, and Rails documentation split into dedicated files. PR #253.
Laws written in Ruby, Angular, and the rule forms only a graph can state
Architecture constraints can now be written as Ruby sentences, parsed and never executed. The rule vocabulary reaches 21 forms, including the ones that need the whole graph. Angular is read the way the framework works — decorators, dependency injection, composed routes and templates. Convention recipes ship with the binary, so a repository adopts them in one command.
- AddedConstraints may be written in Ruby. Files ending
.rbunderenola/constraints/are parsed with the same grammar the extractor uses and never executed, compiling to exactly what the YAML loader produces — same merge order, same evaluator, same lint surface. Nineteen verbs cover the rule forms. - AddedAngular extraction:
@Component/@Directive/@Pipe/@Injectable/@NgModuleclasses and the role the container gives them, constructor andinject()dependency injection as edges, routes composed outward fromforRoot/provideRouterincluding lazyloadChildren, templates joined to the component that owns them,@NgModulecomposition arrays, and requests made through an injectedHttpClient. - AddedRule forms only a graph can state:
storage_stays_home,cap_runtime,require_consumer,unique_across,require_governed,forbid_cyclesandindependent— taking the vocabulary to 21 forms. - AddedConvention recipes ship with the binary.
enola constraints initbinds every recipe whose roles resolve to directories the repository has —rails-conventions,rails-strict,vanilla-rails,layered,ports-and-adaptersand more — naming what it could not bind and guessing nothing. - AddedA component may select by ancestry (
ancestor:), read transitively over resolved inheritance. Rubydex joins Prism as a Ruby fact provider carried by the binary itself. - Added
sinceandgrowthput a time dimension on a rule, and every edge or cycle verdict names its smallest cut. - FixedA snapshot no longer depends on whether a previous snapshot exists: markdown links resolve against the walked file list rather than the filesystem, and the output directory is ignored at any depth rather than only at the repository root.
Rails route parity, fewer query-loop false positives, and utoipa routes
Rails route derivation matched Rails across a 3,500-route application. This release also adds dead-method detection and utoipa routes, improves query-loop analysis, and cuts a 22-repository cluster rerun from 891s to 224s.
- AddedRails routes now derive the way Rails derives them — plural/singular naming,
scope module:/controller:composition, leading-slash escapes, and the hash-rocketmountform — checked againstActionDispatch::Routing::RouteSetline for line on a 3,500-route application. - Added
dead-methodsreports Ruby methods that no call edge in the graph reaches. - Fixed
query-loopsnow follows a relation back to where it was built and accounts for declared preloads, removing two classes of false positive. - AddedExternal fact providers can contribute lint findings through the same seam as other facts. A reference ESLint provider ships as a worked example, and
constraints mine --scaffold-eslintwrites a mined convention out as an ESLint rule. - AddedRust services using
#[utoipa::path](utoipa_axum) now have their routes extracted. These routes were previously invisible because there is no.route(path, …)call to read. - PerformanceCluster generation across multiple repositories does each shared step once instead of once per repository. A 22-repository, 1.56M-fact cluster went from 891s to 224s on a rerun.
Action: extractors changed, so regenerate snapshots and re-pin baselines.
Implementation notes and pull requests
Rails expectations ran through ActionDispatch::Routing::RouteSet on actionpack 8.1.3 and 8.1.1. Handlers naming an existing controller rose from 3,482 to 3,536, with no route assigned to the wrong controller. The Ruby extractor also adds JSON:API resource classes, symbol-based method references, and receiver chains in block bindings. dead-methods stays limited to surfaces whose callers the graph can see; every result is a candidate, not a verdict. Cache versions v216 through v223. PR #244.
utoipa_axum::routes!(find_team) registers a handler without repeating the path stored in #[utoipa::path(...)]. Before this change, crates.io's API stored 8 routes where it serves 59. The extractor handles multiple verbs and context_path, and skips non-literal paths rather than guessing. OpenApiRouter::nest() prefixes are not yet supported. Cache version v224. PR #245.
Fact paths fixed on Windows; docs checked against the code
A declared layer order could silently match zero modules on Windows. Every fact path is now forced to forward slashes regardless of host, and a new CI check catches documentation that has drifted from what the code actually does.
- FixedFact paths — module names, layer classification, ignore globs, cross-repo linking — always use forward slashes now, even when generated on Windows. The host's backslash paths previously stopped matching everywhere paths get compared, so a declared layer order could report itself valid while classifying zero modules.
- FixedThe
query_insightsMCP tool'sexplainer=filter description named eleven explainers while the engine ran sixteen. Five —intent,constraints,domain,query-loops,entry-points— were invisible to an agent deciding what it could ask for, even though their findings already existed. - FixedSeveral docs pages had explainer, tool, and rule-form counts that had silently drifted from the code, and two links pointed at files that had been renamed. A new CI check (
docslint) now catches this going forward instead of publishing it.
Action: none for Unix users. Windows users should regenerate snapshots and re-check declared layer orders — matches that were silently empty before now report correctly.
Implementation notes and pull requests
path/filepath is the host filesystem's dialect, and its output carries backslashes on Windows — filepath.Dir("src/lib/x.ts") returns src\lib there, because Clean ends in FromSlash. Every downstream consumer that splits fact paths on "/" silently stopped matching, which is how a declared layer order came to classify 0 modules on Windows while reporting itself valid (issue #242). A new internal/factpath package covers the operations that build a path — Dir, Clean, Join, Split, Match — normalising to forward slashes on every host, with a contract test enforcing the split from plain path/filepath use. Cache version v215 to v216. PR #243.
docslint checks prose against the code it describes: that a number in the docs equals a number derived from config.KnownExplainers, cli.OSSTools(), intent.RuleForms and layers.TaxonomyNames(), that a linked path exists, and that a page carries the sections its own index promises. docs/EXPLAINERS.md had claimed both "fifteen explainers" and "sixteen explainers" three paragraphs apart. A frozen number can be waived by phrase, with a reason, so a historical benchmark figure is not flagged as wrong because the code moved on. Runs in milliseconds with no engine or CGO, so it sits in .githooks/pre-push as well as CI. PR #241.
AsyncAPI channels and Kafka call sites, cross-service
Messaging contracts declared in AsyncAPI now join the graph the same way HTTP routes do, so a missing consumer or an undeclared producer shows up as a finding instead of staying invisible.
- AddedAsyncAPI spec files (
asyncapi.yaml/.yml) parse into declared channels, messages, and their schemas. - AddedKafka producer and consumer call sites extracted in Go, TypeScript, and Scala, matched to declared channels by topic.
- AddedA new
messaging-coverageexplainer reports channels nobody produces or consumes, and messaging code with no declared contract — the messaging counterpart tounused-routes. - AddedWorks across repositories: a producer in one service and its consumer in another link through the same multi-repo snapshot that already joins HTTP routes to their clients.
Action: none. Existing snapshots pick up messaging facts on the next generate_snapshot.
Implementation notes and pull requests
A messagingcontract binder links declared AsyncAPI operations to the producer and consumer call sites the extractors find, the same shape as the HTTP route binder. messaging-coverage turns those binding verdicts into three finding kinds, available through query_insights(explainer="messaging-coverage"): a declared channel with no producer, a declared channel with no consumer, and messaging code with no declared contract at all. This is the sixteenth explainer added to config.KnownExplainers. The cross-repo fixture pairs a Go producer service (svc-billing) with a Go consumer service (svc-orders) sharing one Kafka topic across two repositories. PR #239.
Declared architectural law
Architecture rules you write down are now verdicted against the measured graph, checked before an edit lands, and gateable in CI. Tools Enola does not ship can contribute facts through one seam, and the recorded history moves between machines.
- AddedA constraint vocabulary: components select facts, and rules state what may not reach what. Declarations live in
enola/constraints/*.yamlbeside the code they govern, so CODEOWNERS can route each file to the team that owns it. - Added
enola constraints lintreports every validation problem with its file context and resolves each component against the snapshot, so a selector that matches nothing is caught while you are writing it rather than by a rule that passes vacuously. - Added
enola constraints mineproposes candidate rules from regularities the graph already shows, with their evidence and their exceptions. Proposals for review, never self-adopting law. - Added
enola plananswers before the edit: which rules govern the change, its blast radius, and — for a patch — the verdicts that would appear, evaluated over a scratch copy. Nothing is written. - Added
enola endpointwalks a URL to what changing it reaches: the controller serving it, the models it touches, the models associated with those, and the tables behind them. - Added
enola history push|pull|verify|gcshares the recorded architecture history between machines through a plain directory store — a Git repository, a shared mount, an S3-synced folder. Content-addressed and tamper-evident;verifywalks every chain and names gaps and tampering. - AddedA fact-provider seam: an external tool can contribute facts in the store's own schema. Fail-closed end to end — one invalid line discards that provider's whole output, every fact must declare how it was resolved, and the seam stamps provenance itself.
- AddedThree MCP tools:
constraints_for,plan_checkandendpoint_impact. Four explainers:constraints,domain,query-loopsandentry-points. - AddedRuby reads
db/schema.rbanddb/structure.sql, Stimulus and Turbo bindings declared in markup, and model associations. TypeScript adds constructors, decorators, accessors andextendsbases; Go adds interface methods. - Changed
hotspotsnow reports its top 20 per repository. It was around 80% of every finding Enola produced, which buried the other explainers; corpus findings fall from 29,633 to 9,131. - Changed
--fail-onrefuses a name no explainer has. It previously accepted anything and enforced nothing, so a typo in a CI config was indistinguishable from a passing build. - FixedA plain file named
enolaat a repository root no longer aborts the snapshot — building a binary by that name in your own repository was enough to make generation fail.
Action: extractors changed, so regenerate snapshots and re-pin baselines. Two things may need attention: if a --fail-on value in CI is misspelled, that job will now fail rather than silently enforcing nothing — which is the point. And a service whose label came from its directory name may be renamed, since labels derive from the Git remote at a repository root.
Implementation notes and pull requests
Constraint rules are verdicted against measured facts rather than file paths: a component resolves to the facts its match patterns select, and a rule states one of twelve enforceable forms over components — forbid, the transitive forbid_reach, allow-only, protect, private and the rest. A breach is set membership over measured edges, so it carries confidence 1.0 and --fail-on=constraints can gate on it. PR #234, PR #235.
plan --patch applies a unified diff to a scratch copy, re-extracts, and reports the constraint verdicts the patch would introduce and resolve, with a witness path for each. The working tree is never written and a malformed diff is an error rather than a partial application. constraints mine ranks candidates by confidence multiplied by support and renders each as a rule you can paste, deliberately in advisory mode. PR #236.
The provider seam runs an executable once with --version and once with the repository path, reading facts as JSONL. Every fact must carry a resolution_level from a closed vocabulary — the provider's own account of how it resolved what it emitted — and runtime-observed must name its observation channel while declared must name the signature file that claimed it. Provider facts may not collide with an extractor fact's identity, and each run lands in the receipt's census including providers that contributed nothing and why, so a delta whose two snapshots ran different provider sets is never graded as a full verdict. PR #237.
The shared history store is plain files, content-addressed, with each revision carrying the machine that recorded it. pull imports what other machines pushed, so blame on a second clone attributes facts it never generated itself; verify walks every chain and names gaps and tampering; gc prints its retention plan and deletes only with --apply, on record. Working revisions — snapshots of uncommitted trees — are skipped unless --all asks for them. PR #238.
Seventeen extractor changes, cacheVersion v198 to v215. Ruby: db/structure.sql and db/schema.rb read as the database's own account of the schema and folded onto the model that reads each table; Stimulus data-controller/data-action and literal Turbo frame ids recorded at markup-declared; a namespace's table_name_prefix applied to derived table names only; a declared self.table_name now corrects the model's own fact instead of emitting a second one; an abstract class no longer claims a table it does not have; importmap-rails apps detected as JavaScript projects. TypeScript: constructors, member decorators, get accessors, self-assigned fields, parameter presence, extends bases and CommonJS export assignments. Go: interface methods declare symbols. association joins the fact kinds, which is what lets endpoint reach the tables behind a URL.
hotspots also stops counting has_method containment edges as calls, so a large class no longer reads as a pinch point for being large. The --fail-on accepted set, its help text and the default explainer list now derive from one list, which is how four gateable explainers came to go unadvertised in the first place. PR #240.
Nothing fails until you name it
The check gate no longer ships an opinion about which findings should break a build, and repository labels come from the Git remote so a second clone compares against its own baseline.
- Changed
enola checkdefaulted to failing oncycles. It now fails on nothing until--fail-onnames it — a cycle is exactly measurable and still contested, so shipping it as a default asserted a rule about a codebase Enola had not looked at. - AddedA run that enforced nothing now says so, rather than printing a bare pass. A silent green is indistinguishable from a broken gate.
- ChangedA repository's label comes from its Git remote when the indexed directory is that repository's root, and from the directory name otherwise.
- FixedA label mismatch between a snapshot and its baseline is now blocking rather than graded, so a worktree no longer reports its entire graph as added and removed.
Action: if you relied on the old default, add --fail-on=cycles to your CI job explicitly. Service names taken from a directory may change where the Git remote differs.
Implementation notes and pull requests
The default policy was cycles — exact, but an opinion: Go's compiler already forbids package-level cycles, and a Rails application resolves most of its graph at runtime and does not read them as defects. Two consequences follow that the diff does not show. An unenforced run has to state that it enforced nothing, so the session hook now reports the exact findings no policy covered and leaves the verdict to the reader. And informational findings are never gradeable: layers emits an exact finding announcing a declared order, so without that rule the very commit declaring one would fail on its own description. Enola now declares its own layers and gates its CI on them rather than on cycles.
The repository label is part of every fact's diff key, so a worktree or a second clone under another name shared no keys with its baseline and the delta reported the whole graph as added and removed — while comparability, which prefers the remote, waved it through as the same repository. The label now comes from the remote too, but only at a repository root: a remote identifies a root and nothing under it, and labelling sub-directories with the parent's name merged separate graphs, since nodes are name-keyed. PR #233.
Express sub-router mounts compose across files
A router declared in one file and mounted in another now reports its routes at the path they actually serve, which is the layout most Express services use.
- FixedRoutes from a router declared in
routes/*.jsand mounted inindex.jswere dropped entirely, because a per-file pass cannot resolve the mount. A repository-wide pass now propagates mount prefixes from the application roots. - FixedA same-file mount onto an unmounted parent emitted a fragment path; it now emits nothing. An extensioned
./x.jsspecifier now resolves to thex.tsit names. - Fixed
installanduninstallprinted the full plan twice when--yesleft no prompt between the preview and the result.
Action: cacheVersion moves to v198, so cached TypeScript snapshots re-extract on the next run.
Implementation notes and pull requests
A repository-wide fixpoint propagates mount prefixes from the application roots, the same shape the Go and Axum extractors already use for their own mounts. It covers ESM and CommonJS, renamed named exports, factory-returned routers, transitive nesting and a router mounted twice. A non-literal prefix, an external module or an unmounted router still emits nothing: the pass can correct a path but never invent one. PR #232.
Lower peak memory, and a verdict that says why nothing failed
Cold-run peak heap drops by a quarter to two fifths on large repositories, and advisory findings now name the reason they did not fail the build.
- PerfCold peak heap falls 24% on a large C# repository and 40% on the Linux kernel; the
--explainreport falls 41% and 43%. Output is unchanged — the determinism suite is the proof. - ChangedThe extractor cache streams to disk instead of being buffered whole, and unsaved cache writes are skipped.
- FixedAn advisory finding now says why it did not fail: below the confidence floor, or from an explainer no policy named. One line could not cover both, and the old text asserted "Confidence < 1.00" over findings printing exactly 1.00.
Action: none. Peak memory is a property of the run, not of the output.
Implementation notes and pull requests
Peak has to be sampled to be seen at all: the engine's end-of-snapshot log line runs after FreeOSMemory and describes the survivor, and Darwin keeps freed pages resident so RSS reports neither. On a large repository the peak is roughly six times the steady state. Three independent changes bring it down — skipping unsaved cache writes, pacing the collector for the duration of a snapshot, and dropping copies — none of which alters output. The GC pacing is scoped to the call rather than set at startup, because it is process-global and importing the engine must not re-pace a host process's collector; an explicit setting in the environment wins. The warm peak does not move, which is itself the finding: it is live retained bytes rather than collector headroom. PR #230.
Advisory findings now name their own reason for not failing. PR #231.
Full Rails route tables, plus Grape
Rails routes are now read from every routes file, not just the top-level one, and Grape APIs are detected automatically.
- AddedRoutes now come from every
config/routes.rband every.rbfile underconfig/routes/at any depth, withmountresolved to the mounted engine's own route file. - AddedRoutes carry a
handled_byedge to their controller action, and Rails classes carry arails_componentprop. - AddedGrape API classes are now detected automatically by transitive inheritance, without needing a route file of their own.
- FixedThe Ember util layer no longer claims Ruby
libdirectories in a mixed Rails/Ember repo — layer matching is by path segment, andlibisn't a real Ember layer.
Action: regenerate snapshots for Rails repositories to pick up the full route table and Grape detection.
Implementation notes and pull requests
Routes now come from every config/routes.rb and every .rb under config/routes/ at any depth, each parsed under the prefix that draws or mounts it, with mount resolved to the mounted engine's own route file. Routes carry a handled_by edge to their controller action, the walk descends into Ruby control flow, and classes carry a rails_component prop. Grape is identified by transitive inheritance rather than by a route file, computed from the superclass props the AST pass already emits: a codebase typically has one class inheriting Grape directly and hundreds inheriting that, and deriving the set from facts that already exist costs no extra I/O where there is no Grape. Also drops lib from the Ember util layer — it's wrong on Ember's own terms, and since layer matching is by path segment, it was claiming Ruby lib directories in a mixed repo and turning almost every edge out of them into a violation. PR #229.
README now documents how to use the GitHub Actions Marketplace CI gate directly. PR #228.
Five grammar updates and a protocol bump
Java, Ruby, C, C++, and PHP move to newer tree-sitter grammars, the bundled MCP SDK updates to the latest protocol, and CI gets hermetic tests and pinned tool versions.
- ChangedJava, Ruby, C, C++, and PHP tree-sitter grammars all updated to their latest upstream versions.
- ChangedThe bundled MCP SDK updates to v1.7.0, tracking the 2026-07-28 protocol revision.
- FixedGit-dependent tests no longer pick up the machine's own git config, so results stay the same in CI and locally.
- ChangedCI now runs a dedicated probe test for every supported tree-sitter grammar, plus actionlint and pinned action versions.
Action: none. Regenerate a snapshot if you want facts extracted with the newer grammars.
Implementation notes and pull requests
Java grammar to v0.23.5 and Ruby to v0.23.1 (cacheVersion v195); C to v0.23.6, C++ to v0.23.4, and PHP to v0.23.12 (cacheVersion v196) — a stale cacheVersion is what forces re-extraction on upgrade rather than serving facts parsed under the old grammar. PR #221, PR #225.
The bundled MCP go-sdk moves to v1.7.0, tracking protocol revision 2026-07-28. PR #227.
Git-dependent tests previously inherited the host's ambient git config (user name, signing key, aliases), so a contributor's local setup could pass or fail a test CI would not; tests now run against a hermetic, test-local git config. PR #208.
CI now runs a probe test for every supported tree-sitter grammar rather than relying on the extractor test suite to notice a broken parser, adds actionlint, and pins tool versions instead of floating on a version range. PR #226. The repository's own CI now runs on enola's GitHub Action instead of hand-rolled steps. PR #220. Dependabot is configured for gomod and github-actions, PR #207, with ignore rules added to cut the churn it would otherwise generate, PR #222: actions/checkout to v7 (PR #214), actions/setup-go to v7 (PR #212), golangci-lint-action to v9 (PR #211), actions/upload-artifact to v7 (PR #210), softprops/action-gh-release to v3 (PR #215), and golang.org/x/sys to v0.47.0 (PR #217).
Vendored OpenAPI specs no longer read as your own server routes
A mobile app vendoring the OpenAPI spec of a backend it calls was indexed as if it served those routes itself, manufacturing inbound edges and unused-route findings that don't exist.
- FixedOpenAPI specs vendored by a native mobile client in a bare
openapi/directory were classified as server routes; they're now demoted to client routes when the repo looks like a native app and declares no server route of its own. - AddedA Next.js
loading.tsxunder a single dynamic segment now extracts as a route, matching any one-segment client call with a path parameter.
Action: regenerate snapshots for repositories that vendor OpenAPI specs to drop the phantom server routes and findings.
Implementation notes and pull requests
The OpenAPI extractor defaults every spec to role=server, demoting to client only for the openapi/client/ directory convention. Mobile apps vendor the specs of services they call in a bare openapi/ dir, so those were indexed as served endpoints: an Android app acquired inbound edges from three backends and 28 unused-route findings. A pre-link binder now demotes a repo's OpenAPI routes to role=client when the repo ships a native-app HTTP client (retrofit, urlsession, swift-endpoint, or dart) and declares no server route outside a spec. The native-app marker is positive evidence, so spec-first backends serving only from api/openapi/*.yml are untouched. Rewritten facts carry vendored_spec=true, keeping the change auditable and reversible. Also adds "loading" to uiRouteTypes: a Next.js loading.tsx under a single dynamic segment extracts as /{}, matching any one-segment client call with a path parameter. PR #206.
The update-available notice actually gets written now
The check for a newer enola release only ever ran from two entry points, so most installs never got the cache file it depends on. It now refreshes from any command.
- FixedThe update-available cache only refreshed from the session-start hook and the MCP server's boot; an install using neither never wrote it and silently never notified.
- FixedA command that exits fatally with "snapshot produced no facts" now also prints the update notice, since that failure is often actually caused by running a build that's behind.
Action: none. The refresh still runs in a detached background process, so no command anyone waits on touches the network.
Implementation notes and pull requests
Refresh previously ran only in the session-start hook and the MCP server's boot goroutine, so an install using neither path never wrote ~/.enola/update.json and every reading surface read an empty file forever. Commands now spawn a detached child to perform the refresh, keeping the network off any path a person waits on. cmdFatal now prints the notice too: when the extractors have moved on, an old build detecting no language is what produces "snapshot produced no facts," so the failure most likely to actually be caused by running a stale build was the one path that stayed silent about it. PR #205.
Uninstall leaves nothing behind
enola uninstall now removes the empty files and directories it leaves behind, instead of stripping its own configuration out and abandoning the shell.
- FixedA hook config emptied by uninstall used to leave a stray
{}file and its directory behind; both are now removed when a mutation empties them. - FixedThe heartbeat's output directory is now removed on uninstall too, when nothing else is left inside it.
Action: none. Existing installs are unaffected until the next enola uninstall.
Implementation notes and pull requests
Stripping enola's keys from a hook config left {} and its directory behind; a config the mutation empties is now removed outright, and all three removal paths prune what they emptied. Pruning stops at any directory whose existence gates the install: enola never creates those and only reads them to decide whether the tool is present, so removing an empty one would reverse a state enola never caused. PR #204.
Dart and Flutter extraction, gin routes, and update notices
Enola now extracts Dart and Flutter apps — widgets, navigation, storage, and outbound calls — plus gin routes in Go, and tells you when a newer enola is available.
- AddedDart and Flutter are now extracted: symbols, imports, the call graph, and complexity metrics, plus Flutter widget roles, navigation routes, outbound HTTP calls, and local/remote stores.
- Addedgin routes are now extracted from the Go extractor, including
Groupprefixes and handlers registered viaAnyor the genericHandle. - AddedEnola now checks for a newer release at most every 12 hours in the background and prints a notice — never on a command a person is waiting on.
- FixedA Dart project's
pubspec.yamlwas silently excluded by the default ignore globs, so every legal Dart import cycle was misreported and Flutter projects went undetected. - FixedA Dart call resolving to a same-named constant (not just callables) could misattribute high fan-in to the constant instead of the real function.
Action: regenerate snapshots for Dart, Flutter, and Go/gin repositories to pick up the new facts. No configuration change is required for the update notice.
Implementation notes and pull requests
Every Dart framework pass is gated on the file's own imports, which Dart makes a language guarantee rather than a heuristic — imports are mandatory and there's no ambient namespace, so a file that never imported go_router cannot be calling it, which is what makes it safe to match on short names like go and get. Flutter navigation routes carry type="page" so they stay out of the cross-repo server-route index: a screen's /users/:id is not an endpoint anything can call, and indexing it beside a real backend route of the same shape would manufacture an edge in the wrong direction. Dart permits circular imports between libraries outright, so module facts now carry pub_package and a Dart cycle is treated as a coupling signal rather than a build-order defect. Generated Dart (.g.dart, .freezed.dart, .mocks.dart) produces nothing, since it's the majority of files in a build_runner project and none of it is navigated. PR #202.
gin's Group("/prefix") mounts are joined onto their routes rather than concatenated: gin's own no-prefix idiom is Group("/"), which real servers lean on — ente's opens seven — and naive concatenation turns every route beneath one into a doubled-separator path nothing serves. Group is recognized by its argument being a string literal rather than by name, since chi declares a same-named Group that takes a function and mounts nothing entirely different in meaning. Verified on ente's server: 359 registrations, 359 routes extracted, and its Flutter client's 167 previously-unresolved call sites all resolve. The Dart package index itself is read directly from disk, the same deliberate bypass the OpenAPI and Symfony route readers already make, since **/*.yaml sits in the default ignore globs and a pubspec would otherwise never reach an extractor. Extractor cache versions v190–v193. PR #202.
The update check is cached in ~/.enola/update.json and refreshed at most every 12 hours from a detached session-start child and the server's own boot goroutine, so no command a person waits on ever touches the network. The notice shown to an agent deliberately names no upgrade command: an agent told to run enola upgrade mid-task would do so regardless of context, and it wouldn't affect a server process already running from the old binary's inode. The human-facing notice keeps the imperative instruction instead. PR #203.
Scala extraction and native Codex hooks
Enola now extracts Scala — call graph, routes, storage, and complexity metrics tuned for its effect-typed idioms — and enola install --hooks wires real session hooks for Codex.
- AddedScala is now extracted: symbols, the call graph, complexity metrics, and dependency injection across a fourth JVM language sharing packages with Java and Kotlin.
- AddedPlay Framework routes (including
conf/routes), Pekko/Akka HTTP, and http4s route trees are extracted, along with Scala storage facts and outbound clients. - Added
enola install --hooksnow wires real SessionStart/Stop hooks for Codex, matching its native hook schema. - FixedScala anonymous class bodies (
new T { ... }) are now walked instead of skipped, recovering thousands of declarations and calls that were producing false dead-code positives. - FixedA parenless Scala method reference — its uniform access principle drops the parentheses — now counts as a call edge instead of being silently dropped.
Action: regenerate snapshots for Scala repositories to pick up the new extractor. Codex users should run enola install --hooks and approve the new hook once via /hooks.
Implementation notes and pull requests
Scala's extractor assumes it does not own the repository: apache/spark holds 1,355 .java beside 6,275 .scala in the same packages, so the shared JVM package index now reads Scala too and the Java/Kotlin extractors invalidate on it. The grammar is pinned to tree-sitter-scala v0.24.1, the newest ABI-14 release, since v0.25.0+ are ABI 15 and the vendored runtime rejects them silently. Complexity metrics discriminate a loop from an effect-typed for comprehension by the yield keyword — measured at 60.4% effect-typed for for ... yield versus 9.7% for bare for across 8,119 production files — so sequencing three fetches doesn't report as O(n) per-iteration I/O. Call resolution binds a receiver whose type the source declares (constructor and method parameters), which lets performs_io cross the constructor-injection boundary common in Scala services. PR #200.
Play's conf/routes (and included *.routes files) is read directly from disk, since it has no extension no glob would admit; a sub-router's mount prefix composes onto its paths unless they already carry it segment-wise, which blind composition once turned into /team/team. Pekko/Akka HTTP and http4s route trees are read from the AST, gated on the file importing the framework — an unscoped pass once turned a metrics timer's path(result).record(nanos) into a phantom route and a forum's closeTopic/hideTopic into seven phantom Kafka topics. A trait is only counted as an abstraction when it declares something abstract, since Scala traits routinely carry full implementations; a case class carries the data_holder marker the same way a Kotlin data class or C# record does. PR #200.
The anonymous-class walker fix alone recovered 5,673 declarations and 9,637 calls across 1,817 previously-unwalked bodies — Scala's idiomatic home for implementations, and the largest single cause of false dead-code positives found in the corpus. A combinator applied to an Option (xs.find(p).foreach) is now demoted rather than flagged as an unbounded loop, since the receiver — not the combinator name — determines whether it can actually repeat. Scala module facts now carry the build module they compile into, so the cycles explainer can tell a legal within-module cycle from one that a real build tool (sbt, Maven) would actually reject. Extractor cache versions v181–v189. PR #200.
Codex hook installation writes .codex/hooks.json (local, unconditional) or ~/.codex/hooks.json (global, only if ~/.codex already exists), matching Codex's matcher-group schema including its required async field. Codex requires a one-time /hooks approval before a newly written hook runs, which enola cannot grant on the user's behalf. PR #201.
Understand the architecture of an entire .NET solution
Enola now reads every language in a .NET solution, not just C#, and understands how the pieces are wired together — project references, EF Core storage, outbound HTTP calls, and dependency injection.
- AddedF#, VB.NET, Razor/Blazor, and XAML (WPF, WinUI, MAUI, Avalonia) are now extracted into the same fact set as C#, so cross-language references resolve.
- AddedMSBuild project and solution files are parsed, so .NET dependencies and cycles are judged at the real assembly boundary instead of by directory.
- AddedEF Core, Dapper, and MongoDB storage facts are extracted, along with outbound HTTP client calls and conventional MVC routing.
- FixedClasses registered only through dependency injection (
services.AddScoped<IFoo, Foo>()) no longer read as dead code. - Fixed.NET test directories named
Test/orTests/were missed by case-sensitive globs and indexed as production code; this is now fixed.
Action: regenerate snapshots for .NET repositories to pick up the new languages, storage facts, and dependency edges. No configuration change is required.
Implementation notes and pull requests
MSBuild project files are parsed rather than read by path: a ProjectReference becomes a module depends_on edge, giving cycles and package-metrics the assembly as their real unit instead of the directory, and a PackageReference becomes a nuget dependency fact. Project files of every .NET language are read (.fsproj, .vbproj), which is what stops a solution like Giraffe — a .slnx with no .cs — from matching the extractor and emitting zero facts. Razor components merge with their .razor.cs code-behind, carrying @onclick handlers, @bind targets and @inject types as references; MudBlazor reported 5,749 orphans of 9,287 symbols before this. XAML documents merge the same way with their code-behind, covering WPF, WinUI/UWP, MAUI, and Avalonia's .axaml. VB.NET is read line-oriented, since it has no maintained tree-sitter grammar, into the same type index as C# — Roslyn's own VB compiler is 3,652 files, and 6,644 of its C# orphans had callers only there. F# adds module-level free functions, a construct no other .NET language has. Extractor cache versions v172–v176. PR #199.
storage facts were zero across all fourteen .NET repositories in the benchmark corpus, EF Core products included: a DbContext now becomes a storage fact, with DbSet<T>/IEntityTypeConfiguration<T> types as entities named for the resolved symbol rather than the directory, plus Dapper's generic query methods and IMongoCollection<T>. Outbound HTTP calls (HttpClient verb calls, Refit attributes) become client routes, with the request path resolved through the same per-member literal environment the conventional-routing pass uses; an absolute URL under Aspire keeps only its path, since the host is a service name rather than a hostname. Conventional MVC routing reads MapControllerRoute/MapAreaControllerRoute registrations directly — OrchardCore declares 288 verb attributes across 114 controllers and only 7 carry a [Route] — and a template still containing an unsubstituted {controller}/{action}/{area} is left unemitted rather than guessed. DI registrations (services.AddScoped<IFoo, Foo>()) are read as references, recovering 441 of bitwarden-server's 1,661 orphan classes (27%) and 59 of eShop's 202. Extractor cache versions v177–v180. PR #199.
C# extraction
Enola now extracts C#: types and members, dependency injection, and both of ASP.NET Core's routing mechanisms.
- AddedC# types, members,
usingdependencies, base lists, and constructor/primary-constructor injection are now extracted, with apartial class's several files merged into one symbol. - AddedASP.NET Core routes are extracted from attribute routing (
[Route]/[HttpGet], including inherited templates) and minimal APIs (MapGroup/MapGet). - FixedA C# call through an interface — the common dependency-injection shape — now produces an inbound edge instead of reading as dead code.
- FixedA qualified reference like
VideoRange.HDRnow emits edges to both the member and its declaring type, so enum members and static fields used without a call stop reading as unreferenced. - ChangedA
foreachor LINQ loop's iterable is now walked in the enclosing scope, fixing spurious O(n²) complexity readings on the most common C# iteration idiom.
Action: regenerate snapshots to pick up C# facts. If your mcp-arch.yaml overrides the extractors: list, add csharp to it — a config written before this release silently disables the new extractor.
Implementation notes and pull requests
A partial class split across files merges into one symbol carrying the union of every half's edges — 7,740 files in the benchmark corpus declare one, and left unmerged the type's edges scatter across facts that each look thinly connected. Only public and protected fields and properties become symbols, since a BCL-scale repository's private state would otherwise dominate the fact set. A bare type reference resolves against a project-wide index rather than the file's own imports, because a C# using opens a namespace and names no type in particular; an ambiguous simple name resolves to nothing rather than a guess. Attribute routing composes a class-level [Route] with a method-level [HttpGet] after the whole fact set exists, walking resolved inheritance edges to find the nearest template — 40 of jellyfin's 64 controllers inherit theirs from a shared base. A controller with no [Route] anywhere in its hierarchy is conventional routing, whose template lives in Program.cs, and now emits nothing rather than the wrong path composed from what's visible. Minimal-API groups (app.MapGroup("api/orders")) are scoped to one method body; an unresolved, non-literal group prefix — a library mounting its surface at a caller-supplied pattern — marks its routes unresolvable rather than publishing a path the library doesn't actually serve. Measured on jellyfin: 422 attribute routes, all 388 distinct handlers resolving; on eShop: 0 → 30 minimal-API routes. Extractor cache versions v164–v171. PR #198.
Declared architectural intent, five new extraction kinds, and a lower memory footprint
Repos and knowledge pages can now declare what the architecture is supposed to be, graded against every snapshot. Coverage extends to five new kinds, Ember support is complete, and large graphs use substantially less memory.
- AddedRepos and knowledge pages can declare intended architecture in
enola-intent.yaml, graded against every snapshot through a newgoverning_intenttool. - AddedNew extractors cover GraphQL, Terraform/HCL, Ansible, and React Navigation, plus Sequel storage facts alongside existing ActiveRecord support.
- AddedEmber and Glimmer support is complete: navigation edges, full resolver joins, engines, and monorepo-safe ignores.
- PerformanceThe fact store's memory footprint on large graphs is substantially lower, using an interned, compressed-sparse-row graph index.
- FixedSequel's call-form superclass declaration now correctly emits a storage fact instead of being silently dropped.
Action: regenerate snapshots to pick up the new route and storage kinds. Declaring architectural intent is opt-in via enola-intent.yaml; see docs/INTENT.md.
Implementation notes and pull requests
Architectural intent compiles into the graph as a fourteenth tool. A repo's enola-intent.yaml, or a cluster-level override, declares what the architecture is supposed to be; markdown knowledge pages opt in with an enola_intent: block naming typed relations, origin channels, and file anchors. Every snapshot verdicts the measured graph against declared intent, and a genuinely unasked question (an absent repo, an unmeasured file kind, foreign vocabulary) is never conflated with a dangling anchor. governing_intent answers the reverse query in both directions, and impact_analysis/show_symbol now carry the governing trail. Calibrated at estate scale: 590 false intent findings before counterparty corrections, 3 genuine after. PR #197.
Deterministic coverage extends to GraphQL (server root fields via the graphql-ruby DSL, client operations from gql tags and .graphql documents, joined cross-repo), a new HCL/Terraform extractor (blocks as addressed symbols, literal references as depends_on edges), a new Ansible extractor (plays, roles, by-name role references, never rendering a template), React Navigation (screen registrations as page routes, navigate() calls as navigation edges), and Sequel storage facts. A shared literal-derivation helper (bounded: single-assignment constants, wrapper-call path literals, interpolation-headed template tails) now backs the HTTP-client scans; the Ember fold map runs through it byte-identically. PR #194.
Ember: Router.map honors resetNamespace, routes gain handled_by edges to their route classes, and <LinkTo>/literal transitionTo/replaceWith become navigation edges. The resolver joins @service fields and service: lookups, ember-data relationships and @attr transforms, adapters/serializers/transforms, contextual components, addon re-exports, engines, and both pods and classic layouts. Container-resolved classes are marked framework_registered so live singletons stop reading as dead code. Heavy-directory ignores are now any-depth, since a monorepo sub-app's nested node_modules previously entered the graph (~880k facts on one production monolith). Validated at 90% invocation resolution with zero parse errors on a large production app. PR #193.
On the Linux kernel's 1.89M-fact graph, the adjacency index cost 854 MiB as two map[string][]Edge-shaped structures, and Props/Relations maps cost a further 858 MiB across 16.8M live objects (44% of retained heap), from the same fact shape being allocated fresh 178,043 times over. The graph index is now CSR (compressed sparse row); File and Relation.Target strings are interned on the way in; and Store.Freeze() deduplicates structurally identical Props maps and Relations slices onto shared instances at the single publication point, since the published bundle is never written again. PR #195.
Sequel's call-form superclass (Sequel::Model(:customers)) was dropped by the superclass reader, so the dataset idiom emitted no storage fact at all; the golden fixture had pinned the miss while the corresponding helper's unit test passed against a narrower case. The superclass is now read whole and stripped to its base name. New extraction docs cover HCL, Ansible, and the new Ruby fact classes. Extractor cache versions: v149–v154 (Ember, coverage extractors), v157–v163 (declared intent). PR #196.
Ember and Glimmer extraction
Enola now resolves routes, templates, services, and data-model relationships in Ember applications.
- Added
.gts,.gjs, and classic.hbstemplates now contribute symbol references without changing source line numbers. - AddedThe Ember resolver links template invocations,
@serviceinjections, and ember-data relationships to declarations underapp/. Ambiguous matches remain unresolved and are counted inember_unresolved. - Added
Router.mapdeclarations produce composed page routes. Route templates attach to route classes, with controllers used as a fallback. Ember-data models produce storage companions.
Action: regenerate snapshots for Ember repositories. No configuration change is required; Ember detection requires ember-source.
Validation and implementation notes
Template tags are replaced with same-length parseable text while preserving newlines. The extractor produced 93,438 facts with zero parse errors on a roughly 6,900-file production application; repeated runs were byte-identical, and 89% of template invocations resolved. Extractor cache version: v148. PR #192.
Incomplete binary releases are no longer published
A release remains a GitHub draft until binaries and checksums exist for every supported platform.
- FixedThe release workflow now verifies all five expected binary and checksum pairs before publishing. A failed or unscheduled platform build leaves the release in draft.
- Fixed
install.shnow identifies the missing OS/architecture artifact and links to the release downloads instead of returning a generic curl error.
Action: none.
Implementation notes
Platform jobs upload tar.gz and sha256 assets to a draft release. A separate publish job compares the expected asset set with the release before changing its state. PR #191.
Architecture history — enola log, show, diff, blame, and backfill
- AddedEvery prior surface describes the tree as it is now,
diff_snapshotincluded, which compares two nows.enola loganswers a question a snapshot structurally can't: when did this happen. Recording is on by default: everygenerate_snapshotappends a revision to an append-only history kept under~/.enola/graphs/<workspace>/history/, outside the repository, so running it against a checkout you don't own is safe and clearing.enoladoesn't take the history with it.enola show <rev>explains one revision,enola diff <a>..<b>compares any two, and agents reach the same two questions through newarchitecture_historyandarchitecture_blameMCP tools. #190 - Added
enola blame <pattern>matches a module or symbol name, a file path, or both endpoints of an edge, and reports the revision where it first appeared;--findingssearches recorded findings instead ("which snapshot introduced this cycle?"),--firststops at the introduction. A revision whose stored contents have aged out is reported as unsearched, never as absent, since "not found" and "not found in what I could read" are different answers and only one means the pattern truly never occurred. #190 - AddedA repository enola has never analyzed still gets a past:
enola log --backfill --sample=daily(alsoall/merges/tags, plus--since/-n/--dry-run) walks existing commit history, extracting each selected commit's tree to a temporary directory and snapshotting it with today's enola and today's config, never the config that happened to be committed alongside that commit, which would make every historical config edit look like an architectural event. It reads the repository and writes nothing into it, and re-running resumes rather than repeating. #190 - AddedA delta is only a description of somebody's change when both sides were produced the same way. Each revision is stamped with an epoch, a fingerprint of enola's version, extraction behaviour, effective config, and plugin sets, and a revision straddling a changed epoch is marked
incomparableinenola lograther than reported as an ordinary architectural change — elapsed time alone never earns that mark. #190 - AddedHistory entries are keyed by
facts.RepoIdentity(the normalized git remote), never an absolute path, so two machines' histories of one repository describe the same subject and are mergeable by union-and-dedup; each revision is stored as a patch against the previous one, with a full copy periodically, at roughly 600 bytes per revision header (measured: ~600B–4KB with contents on a ~4k-fact repo, ~600B–34KB on a ~23k-fact repo).enola gcreports what's stored and reclaims what isn't needed;--thin-older-thandrops old contents while keeping the timeline,--prune-workingdiscards uncommitted-tree snapshots. #190 - ChangedThe history package is deliberately read-only and imports only
pkg/factsand the standard library, staying buildable withCGO_ENABLED=0so a viewer that reads a history doesn't have to link ten tree-sitter grammars. Nothing that judges the present,check,diff_snapshot, freshness, or drift, reads the history: deleting it changes no verdict and nosnapshot_id, per the same authority ruledocs/SNAPSHOTS.mdalready holds everything else to. Experimental: the commands and the on-disk format may still change. #190
Cross-repo linking becomes a plugin architecture
- ChangedExtractor→linker prop values (client sources, route kinds) were spelled out independently on both sides, so the Java extractor's
java-http-clientandfeignsources were never in the linker's hand-written set — Java call sites linked asvia="http"instead ofvia="http-client", silently. Both sides now shareinternal/facts/contract.go, guarded by tests that reject an unregistered route source or a literal spelled outside the contract; 24 files migrated to constants. Java call sites now correctly link asvia="http-client", pinned by a new multi-repo fixture. #188 #189 - ChangedThe engine resolved gRPC and HTTP routes to handlers itself, as four methods in a fixed sequence —
httpbindtestedlanguage == "go",grpcbindcarried a regex per code generator — so a new language meant editing the engine. Binders are nowplugin.Binderimplementations underinternal/linkers/binders, each declaring the stage it runs in; the HTTP binder keys on the structuralhttp_handlerprop instead of a language name. Order-independence is enforced by a test that serializes the store after running binders forward and reversed. #188 #189 - ChangedAll four cross-repo signals — HTTP calls, imports, Kafka topics, shared code — lived in one 1,823-line file with a dedicated field and materializer block each, so a fifth signal meant editing both. Signals are now
plugin.CrossRepoSignalimplementations undersignals/, each declaring a phase and reporting evidence into a sink;crossrepo.gois down to 78 lines of orchestration. A newrouteindexpackage holds the path-matching rules the HTTP signal and both unmatched-route passes must agree on. #188 #189 - AddedCross-repo linking vocabulary — the word lists deciding which names are too generic to link on, and the thresholds signals compare against — is now configurable via a
linking:block inenola.yaml, overlaid underinternal/linkers/vocab. List fields are add/remove rather than replace, so one added base class can't silently discard every default; an out-of-range threshold is a config error rather than a silent clamp. The vocabulary is now part ofcomputeConfigHash, since it changes which edges are drawn. #188 #189 - FixThe bundled
mcp-arch.yamlignores**/*.json, socollectPackageNames— which readpackage.jsonoff the engine's glob-filtered file list — never saw one under the shipped config. No module carriedpackage_name, so the cross-repo linker's own-@scope guard had nothing to fire on: a repo importing its own published package was reported as depending on whatever other repo happened to share that scope name.package.jsonis now read directly from disk, the same principle the OpenAPI and Symfony-config extractors already apply.cacheVersionv145 → v146. #188 #189 - FixThe Axum route rewrite emitted one route copy per mount prefix using
nf := f, which copies the struct but not the map behindProps— a router nested at two mount points produced two facts sharing one props map, so a per-route binder verdict written onto one copy landed on both. Cache-dependent: cached facts round-trip throughjson.Unmarshaland come back with independent maps, so it only bit on a cache miss. Fixed withfacts.Fact.CloneProps(). #188 #189 - FixThe layers explainer ranked
pkg/belowinternal/and fired on any import from the lower rank into the higher one — flagging essentially every Go repository with both directories (15 false findings on enola's own snapshot), since publishing an API over a private implementation is the standard layout, not a violation.internal/,pkg/, andapi/are now collapsed to one tier; the only ordering the layout actually expresses — nothing should import intocmd/— still fires. #189 - ChangedNew
docs/EXTENDING.mddocuments the plugin surface this release created: which plugin kind fits a given problem, adding a binder or a cross-repo signal, thelinking:vocabulary, and the prop-vocabulary contract. Its config example is asserted by a test,TestLinkingExample_FromDocsParsesAndApplies, so it can't silently stop matching what it claims to show. #188 #189
Scope conformance, pluggable measurements & a shared command surface
- AddedA structural diff reports what changed and nothing about what was meant to change. New
internal/conformancetakes a declared target (or the packages a caller expected to touch), runs reverse-dependency impact analysis on the pre-change graph, and reports any package the change reached outside that radius as spillover — the coupling case where a package gains an outbound dependency without any of its own facts moving. With nothing declared, the edited packages are taken as the statement of intent. #186 - AddedConformance is now exposed as
target/expected_packagesondiff_snapshotand as--target/--expectedonenola check, reported as a policy measurement rather than graded in place — it only fails a build under--max-spillover, so passing--targetfor the first time can't silently break a build that never opted in. #187 - AddedA caller running its own analysis over two snapshots had no way to feed the result into the verdict, so it had to grade itself. New
Measurement,Threshold, andBreachtypes letpkg/check'sEvaluatetake arbitrary caller-reported counts and decide what they mean against policy; a verdict with no configured thresholds stays byte-identical to before. #183 - AddedAn analyzer computing a continuous per-module value had nowhere to publish it — one insight per module is noise, publishing only outliers discards the rest, so the value never left a single snapshot's analysis. A new
Annotatorinterface lets an explainer write derived values onto facts after linking and before everyExplaincall, so no explainer's insights depend on registration order; values are rounded before storing, since facts are hashed into the snapshot ID. The fact diff now names which prop moved, askey: before → after, instead of only showing that a fact changed. #184 - Changed
check,coverage,doctor,baseline,install/uninstall, andhooklived incmd/enola, unreachable from a wrapper binary built on internal packages that don't cross a module boundary. Moved topkg/commandbehind aRunnerholding the binary name, so usage lines and error prefixes name whichever binary is actually running;--helpoutput is unchanged. #180 - Fix
ModuleDirstripped the repo label unconditionally, so a top-level source directory sharing the repository's own name resolved to a name no module had — edges out of that tree hung off a phantom node, taking cycle and layer detection with them. NewModuleDirCandidatesreturns both the raw and repo-stripped directory, and callers try exact matches against both before walking up. #178 - Added
compareMetacompared extractor sets between snapshots but never explainer sets, so an explainer present on only one side made its entire finding set read as new or resolved with no warning andComparable: true. NewWarnExplainerSetadds the same symmetric check, registered in the verdict's advisory summary. #179 - ChangedThe working-tree drift warning was computed inline inside the
diff_snapshothandler, so any other consumer building its own delta had to reimplement it — and the wording had already begun to diverge. Shared via newinternal/drift, exposed out-of-module aspkg/bootstrap.AddDriftWarning. Also pins the diff's attribution contract: a cycle closed between two modules that both pre-date a change attributes through the added edge's endpoints, while a finding citing nothing the change touched stays incidental. #182 - Changed
ResolveBaselineDir— the shared contract behind whatdiff_snapshot,compare_receipts, andenola checkmean by "previous" — is now re-exported frompkg/bootstrap, so an out-of-module consumer no longer has to spell the baseline subdirectories out locally and risk disagreeing silently once the rotation changes. #181 - ChangedREADME and
docs/BENCHMARKS.mdrefreshed against re-measured numbers. #177
Python inheritance edges, and explainer findings that attribute to the right fact
- AddedThe Python extractor now resolves
implementsedges for inheritance relations, takingimportcontext into account — previously the only extractor missing this relation entirely. #175 - FixLayer-violation findings cited only the importing file, not the underlying dependency fact or raw import target; since the diff engine matches fact names and edge endpoints, file-only evidence never attributed, so every new violation landed in the incidental bucket the gate doesn't grade. Findings now key on the actual fact (layer violations, unused routes), and a new
common.ModuleDirresolves cross-repo file paths back to modules so a composed multi-repo graph doesn't resolve to phantom nodes with undetected cycles.god-classand the layers pattern now clamp atcommon.MaxHeuristicConfidencerather than 1.0, since 1.0 is what the receipt, dashboard, andquery_insights(min_confidence=)read as a structural fact, and a cycle should be the only finding that reaches it.layersandcomplexity-outliersnow also exclude test code from their populations, matching every other explainer. #176
Config resolution, agent-hook reliability, and reproducibility fixes across explainers and extractors
- FixConfig resolution could silently analyze the wrong config: lookup fell back to the binary's own directory, and a list-valued key like
extractors:replaced the built-in list rather than extending it — an eleven-extractor config sitting beside ago buildoutput disabled Rust for every repository that binary touched, from any directory without its own config, with zero facts and zero error.bootstrap.ResolveConfignow returns and prints the resolved path on every command; the engine detects extractors disabled by an incompleteextractors:list and records them asshadowed_extractorsin the receipt. Shipped configs no longer declare extractor lists at all —full.yamlhad claimed to enable all languages while omitting grpc, openapi, and python. #166 - Fix
install --hooks's Stop hook was registered as a flat list, but Claude Code expects every hook event as matcher groups — it parsed, and never fired. The correctly-shaped SessionStart hook beside it didn't fire either, since one malformed entry invalidates the whole hooks block:install --hooksconfigured nothing while reporting success. Every event is now written grouped, with migration for existing installs (enola's legacy flat entry is dropped, foreign entries preserved) and an opt-in end-to-end test that installs the hooks, closes a real dependency cycle in a headless session, and asserts the hook actually ran. #167 - FixA hardcoded
.enola/**ignore-glob literal only matched the default output location by coincidence — pointingoutput.diranywhere else made each snapshot walk the previous one's own artifacts, so an unchanged tree produced a different snapshot every run, breaking the reproducibility the baseline diff depends on. The ignore glob is now derived fromoutput.diritself, which must now name a subdirectory of the repo (absolute paths and..escapes rejected); a config that exists but is invalid now errors instead of silently falling back to defaults that analyze the working directory. #168 - Fix
MeanStdDev's float reduction ran over the fact store's insertion order, which varies between runs of an unchanged tree due to concurrent extraction — andgod-classis the only explainer that puts a threshold into its output (confidence) rather than using it only to select. Across 90 runs of 30 repositories, 11 of 25insights.jsonfindings drifted by ~1.9e-15, invisible at the two decimals anyone reads but enough to breakinsights.json's output-hash reproducibility.MeanStdDevnow sorts a copy before reducing. Also fixesinsights.jsonserializing asnullinstead of[]for a repository with no findings. #169 - AddedA hook that never fires and a hook that fires and finds nothing look identical from inside a session — the Stop-hook registration defect above shipped silently for exactly that reason. Every hook invocation now stamps
<output.dir>/hooks.json, including no-op runs;enola doctorreports when each hook last ran and what it concluded (always exits 0, since a diagnostic can't borrow the non-zero-means-regression convention); and a pre-push git hook runs the agent-hook end-to-end test when a push touches the installer, since CI runners can't run real agent sessions. #170 - FixThree extraction gaps surfaced by widening the benchmark corpus to languages it had no prior coverage for: Kotlin/Retrofit only matched positional
@GET("...")arguments, missing the named-argument form (@GET(value = "...")) real Android code uses, so no mobile-to-backend edges existed for it at all; Axum route chains terminated at the first non-verb method (e.g..layer(mw)), dropping routes with the verb in plain sight; and TypeScript path-alias resolution picked the first matching prefix by map-iteration order instead of the longest, the only reproducibility failure found in the corpus.cacheVersionbumped to v144, v145. #171 - Fix
examples/multi-repo.yamlclaimed in its own header to enable all extractors “regardless of which languages each repository uses,” then pinned five of twelve — a cluster containing PHP, Rust, Python, Java, or C++ indexed those repos as unsupported. A multi-repo cluster config is the one place the language mix can't be known in advance, so it no longer pins a plugin list; per-language example configs keep theirs. Also republishesdocs/BENCHMARKS.md(38 repositories, 4,211,113 facts, zero parse errors, 38/38 byte-identical across 114 runs) and widens the regression ratchet to 12 repositories and 8 languages. #172 - FixThe Stop hook only emitted when a diff found a regression, so a baseline that couldn't be compared at all looked identical to a clean session — nothing was ever graded.
shouldAutoPinnow refreshes an auto-pinned baseline that's blocking-incomparable, not just one whose tree moved; the hook emits on incomparability too, naming the cause and remedy from the same tableenola checkprints from; anddoctorreports baseline usability before a session starts rather than after. #173 - ChangedAdded
docs/SNAPSHOTS.md, documenting the design argument for why enola's graph is a computed snapshot rather than a continuously maintained one: a verdict is a function of two states, and updating a graph in place would destroy the earlier state with the very edit being graded. Corrects a benchmark citation (30 repositories vs. the actual 38) and a mischaracterized cost comparison indocs/BENCHMARKS.md, and trims retrospective narration fromARCHITECTURE.mdanddocs/extraction/python.mddown to present-tense statements of each rule. #174
See which cross-repo edges resolved, from the CLI
- Added
enola coveragereports which cross-repo edges actually resolved, now callable from the CLI instead of only through an MCP tool call — extracted intopkg/coverageso both surfaces compute the same report. The unresolved list is unconditional, not a flag: a report showing only successes would be marketing, and each miss is labeled as a repo you haven't loaded, a third-party endpoint, or a genuine blind spot. Exits 0 whenever it ran (2 on a usage error) — it's a report, not a gate;checkowns the meaning of a non-zero exit. Also adds anexamples/cross-repopair whose link depends on a prefix composed across a function boundary, plus one deliberately dynamic call that stays unresolved, with a test that fails if the mount is removed. #165
enola check: grade architectural regressions from the CLI or CI, wired into agent sessions
- AddedGrading an architectural diff previously required an agent to remember to call an MCP tool. A pure
Evaluate(*diff.SnapshotDiff, Policy) Verdictnow backs a newenola checksubcommand, exiting 0 clean / 1 regression / 2 could-not-run / 3 declined — 3 is deliberately distinct from 1, since a delta built over different inputs describes how the snapshots were produced, not what was edited, and grading that as a failure would be a lie. Comparability gains a typedKindset instead of a single bool, so a stale baseline now warns and still grades rather than hard-refusing. Policy gates on the explainer (cycles by default), not on raw confidence, sincegod-classandlayersfindings both use 1.0/0.4 for reasons unrelated to certainty. Also addsbaseline pin|show|clearand rejects unrecognized arguments instead of silently absorbing them as config paths. #161 - AddedA baseline pinned on one machine can now be graded on another. Comparability previously compared absolute
RepoPath, so a baseline pinned in a CI runner's workspace and restored on a developer's machine always read as a different repository — the delta was correct, only the verdict was wrong. Repository identity now comes from the git remote, normalized (host/path, with scheme, credentials, port and a trailing.gitstripped) so an HTTPS clone with an injected CI token and an SSH clone of the same repo compare equal, falling back to checkout directory name when neither side has a remote. CI gains an advisoryarchitecturejob that grades every PR against a published baseline without ever failing the build. #162 - Added
enola install --hookswires the check loop into an agent session automatically: a baseline pins when a session starts, and the architectural delta is reported when it ends, only if the change introduced a regression. The stop hook is advisory by default (emits context, always exits 0), and the session-start snapshot runs detached so a large repo's index time never stalls session start — measured at the binary's floor (~17ms) on a repo that takes roughly ten seconds to index. Concurrent sessions are single-flighted through a non-blocking file lock, and a deliberately pinned baseline is never overwritten by the hook's own auto-pins. Two engine fixes this depended on:copyArtifactsnow publishes a baseline atomically (a failed pin no longer destroys the previous one), and the extractor cache now records the binary that wrote it, after a missedcacheVersionbump was observed serving 568 phantom facts from a stale binary. #163 - ChangedRewrote the README and CLI docs to lead with architectural regression testing — the
check/--hooksloop shipped in this release — instead of cross-repo linking as the primary pitch, and added CLI reference docs and two agent-session screenshots. #164
Traversal cap fixed to bound output, not the walk, and staleness checks scoped to the loaded graph
- Fix
traverseFrom'smaxNodescap stopped the BFS frontier outright instead of bounding only the returned set, so every node reachable only through a capped-out node was silently missed — andNodesVisited/MaxDepthReached, computed over that truncated walk, were reported as properties of the whole graph. The frontier now queues a node even after the cap is hit, matching the existing node-kind-filter contract;maxNodesbounds the returned set only. Also fixes a fused impact-summary statistic: the dependent count came from an uncappedreachableCountwhile the depth printed beside it came from the cut walk. Measured on a 1.9M-fact graph: worst-case traversal 6ms → 22ms; extraction output is unchanged, nocacheVersionbump. #157 - FixStaleness checks pulled both the age signal and the per-repo VCS check from the machine-wide graph receipt, which every server process on the machine rewrites — so it reported drift for repos absent from the loaded graph and missed drift in the ones actually loaded. Receipt entries are now filtered to repos present in the loaded graph, and age comes from the loaded snapshot's own
generated_at, with the receipt as a fallback only when it describes a loaded repo. #158 - Fix
gitInfoflagged a repo dirty on anygit status --porcelainoutput, which lists untracked paths — and the snapshot creates<repo>/.enolabefore the receipt reads git state, so any repo that doesn't gitignore it recordeddirty:truefor a fully committed checkout, disabling the staleness check's VCS arm (which only fires when the recorded state was clean). The status query now excludes the configured output dir via a pathspec, defined once ingitInfoso the snapshot-time capture and the live check share one definition of dirty. #159 - Fix
diff_snapshotandvalidate_architecture_changeboth labelled one side “current” without checking it still matched the working tree; the only age check was relative (baseline vs. current), so two equally stale snapshots compared clean, and an inverted pair was silent because a non-positive gap shared its signal with “no timestamp recorded”.Engine.Driftre-walks under the current config and re-hashes against the snapshot's recorded file hashes, and both tools now surface a comparability warning naming the drifted files — a filesystem comparison, not a VCS one, so it also works for repos that aren't git working trees. Known limit, documented: in append mode, drift checks only the most-recently-indexed repo and reports “cannot verify” for the others. #160
TypeScript server routes, fixed client-call detection, and multi-repo clusters outside MCP sessions
- FixThe optional type-argument group in TypeScript client-call patterns was
<[^>]*>, which can't span a nested type argument —fetch<ApiResponse<Foo>>(...)matched one level of generics but not two, including on the openapi-fetch shape the pattern was written for. Lowercase verb calls (axios.get('/path'),http.post('/path')) matched nothing at all, excluded to avoid colliding withmap.get()/cache.delete(); admitting them required gating client-route extraction onfacts.IsTestPath, since a supertest e2e call is byte-identical in shape to production traffic — one API's own test suite had produced 500+ phantom client routes ungated, promoting an isolated service to connected and fabricating a cross-repo dependency edge out of test traffic.cacheVersionbumped to v141. #154 - AddedTypeScript emitted server routes only for file-based routers (Next.js, Nuxt, SvelteKit); a decorator- or call-routed backend contributed zero routes, so every client call against it stayed unresolved and the backend itself was classified isolated. Two new passes cover NestJS/InversifyJS
@Controllerclasses and Express/Fastify/Hono/Koa<recv>.<verb>('/path', handler)calls, separated from client-call extraction by receiver binding and gated onfacts.IsTestPathso an e2e fixture can't mint routes nothing calls. Also folds four duplicated route-path composition implementations (Axum, FastAPI, Spring, Symfony) into onefacts.JoinRoutePath.cacheVersionbumped to v142, v143. #155 - AddedCross-repo linking was reachable only from an MCP session —
appendis agenerate_snapshottool parameter that both CLIs hardcoded to false, so a CI job or a developer not driving an agent saw only the single-repo subset: no service nodes, no cross-repo edges, nocoverage_report. A config can now name a repo cluster (repos: [../api, ../web, ../sdk]), indexed with one--generaterun — first repo fresh, the rest appended, resolved against the config file's own directory so a checked-in cluster config means the same thing on a laptop and in CI.--explainnow also accepts a cluster config instead of always treating its argument as a repository path. #156 - AddedA live MCP Toplist rank badge in the README. #153
Query credit scaled by the corpus searched, and seeded across restarts
- FixQuery credit was a flat per-tool weight with no corpus term, so the same
query_insightscall earned an identical figure over a 1.9M-fact graph and over a few hundred facts — finding 51 cycles across 55,399 files priced the same as finding them across thirty. A query tool's ordinal weight now scales by the loaded graph's corpus, logarithmically and capped, so a haystack 100x larger costs a few more rounds of greps, not 100x, and a query can never earn more than reading the graph it searched outright would have cost. #152 - Fix
AutoLoadSnapshotrestores a graph after a restart without re-snapshotting, but the corpus pool wasn't seeded on that path, so every query against a restored graph priced unscaled — measured at 23,669 tokens for a call worth 189,836. Each repo's parsed-source size (GraphRepoEntry.SourceBytes) is now carried in the graph receipt and published viaServer.SeedCorpusbefore serving, behind an atomic pointer since it's now read on every concurrent tool call. #152
Value model re-anchored on measured corpus instead of flat call weights
- FixThe estimate behind
--statusand the dashboard was a flat per-tool weight and two conversion constants, so a 72K-token service and a 33M-token monorepo priced identically, a failed call still earned full credit, and theoutput_modeladder — the main token-saving lever — never moved the number. Re-anchored on the counterfactual it claims to measure: what an agent would have had to ingest with grep and file reads to reach the same answer.Store.SourceBytesWithFactsnow sums exactly the files that produced facts (summing the walked-file list instead pulls in images, media, and vendored databases — a 39x difference on one repository); snapshot credit derives from that corpus plus a cross-repo premium and reduced refresh tiers; credit is capped at a repo's own corpus so cumulative credit can never exceed the code it was computed over; and accounting moved into middleware that runs after the handler, so a failed call earns nothing. #151 - AddedCorpora exceeding a context window are now flagged instead of priced — for those the counterfactual isn't expensive, it's impossible, and a token figure would understate it. #151
Python test-file isolation, package re-export resolution & generated-code dead-code signals
- FixThe default ignore globs covered Go, TypeScript, and Ruby test conventions but not Python's: a pytest fixture mounting routers on a throwaway app is a route-mount fact, and the repo-wide FastAPI prefix fixpoint folds every mount it sees, so test-only prefixes rewrote production routes (six phantom endpoints in one repo, ~35% of its facts). Adds
**/conftest.py,**/test_*.py,**/tests/**/*.py, and**/test/**/*.pytoIgnoreandTestGlobs— deliberately not a bare**/*_test.pysuffix, which would repeat an earlier Ruby hazard of swallowing production code. #147 - FixThe bundled
mcp-arch.yamlusers are told tocurlhad drifted behindconfig.Default().Ignore— missing two Ruby spec globs plusdist/,coverage/,.nuxt/,.svelte-kit/, andtarget/— so every adopter of the shipped file silently stopped ignoring those paths. Added a containment guard test. #148 - AddedA production Python symbol exercised only by a pytest file read as dead code (253 of 1,927 orphans in one corpus). New
TestRefExtractorplugin credits these test-only references without polluting the production route graph — it emits onlyKindTestReffacts, so re-reading test files the ignore globs exclude can't reintroduce a fixture'sinclude_routerprefix.cacheVersionbumped to v137. #148 - FixA Python package is a directory, so
from pkg.sub import thingleft a call targetpkg.sub.thingwhose prefix matched no module file: 674 dangling targets carrying 4,660 call edges in one corpus, including every router factory an API composition root mounts, sofind_pathfrom that file reached none of them.resolveCallTargetsnow indexes package__init__re-exports the walker already recorded; exact module resolution still runs first, so this is purely additive. #148 - FixTwo more Python call-target resolution bugs, both producing a wrong target rather than merely an unresolved one. Suffix matching and the internal-root gate accepted any directory name at any depth, so an internal directory sharing a name with a third-party package captured its imports (~500 third-party call edges misclassified as first-party in one repo); both now apply Python's actual package rule — a directory is a top-level package only if its parent isn't one. Separately,
resolveAbsolutedropped trailing segments on failure, which is correct for an import but wrong for a call target:pkg.mod.Cls.methodresolved topkg/modand kept only the last segment, yieldingpkg/mod.method— a plausible-looking but wrong edge, not a dangling one. Resolution now walks the module/symbol split point leftward with an exact module lookup; an unconfirmed multi-segment candidate stays dotted rather than minting a wrong edge.cacheVersionbumped to v138. #149 - AddedTwo new signals let the dead-code detector drop findings it can never act on. Facts from a file carrying a codegen banner (
DO NOT EDIT,generated by,@generated, matched case-insensitively against the leading comment block only, so it works for any generator and any language) now gaingenerated=true, since a generator's unreferenced half is guaranteed noise. Python functions registered with a framework by decorator — FastAPI@app.exception_handler/@app.middleware/@app.on_event/@router.websocket, Modal@app.local_entrypointand (gated on the file importingmodal)@app.function/@app.cls— now gainframework_registered=true, the same treatment Click/Typer commands already had. Facts are flagged, never dropped, so callers of generated code still resolve.cacheVersionbumped to v139. #149 - Fix
importableRootsstopped scanning a path at the first package parent, but a directory without an__init__.pybreaks the package chain and starts a new source root, so a segment below a package can itself be importable — classifying a real internal import as external made a whole analysis tree read as dead. Now judged per position, matchingbuildSuffixIndex, which already did (v138 continued). Separately, a function handed to a decorator as a bare value (@override_run_tasks(handler)) is a real use — the decorator-argument walk looked only for nested calls, so a bare identifier slipped past and the referenced function had no incoming edge. #150
Python FastAPI route composition, safer install script & cross-repo single-segment route linking
- Changed
install.shnow usesinstall -m 755instead ofcpwhen placing the extracted binary, guaranteeingrwxr-xr-xpermissions regardless ofumaskor archive attributes, and unlinking the existing binary before replacing it to avoid aText file busyerror when updatingenolawhile it's running. #144 - AddedRoute decorators on defs nested inside a function body — the router-factory idiom — are now extracted; module-level statement walking never reached them. A repo-wide fixpoint folds
include_routermount prefixes onto the bare decorator path, so a route is stored at the path it actually serves: the Python analog of the Go (v125) and Axum (v130) subrouter composers, keyed by the decorator's receiver rather than by line span. Unresolved mounts keep the bare path. #145 - FixTypeScript module facts now carry
package_name(from the nearestpackage.json), and the http-client verb is read from the call's own options-object literal instead of a flat forward byte window that let a later call'smethod:bleed backwards onto an earlier one. The cross-repo linker also no longer binds a client call to an API-compatible reimplementation elsewhere when the calling repo serves that route itself, and a scoped import under a namespace the consumer itself publishes no longer draws a false cross-repo edge. #145 - FixSingle-segment endpoints could never form a cross-repo HTTP edge, from two compounding bugs:
isGenericPathrejected any path under two segments, sweeping up named endpoints alongside/healthand/status; andpathSuffixesyields nothing belowminSharedSegments, so single-segment server routes were indexed under no key at all. The first is now a vocabulary test, withlinkHTTPrequiring an unambiguous provider for a one-segment match; the second gets a newpathMatchKeyswhole-path fallback used by every server-index builder. Also excludes**/testdata/**from the OpenAPI and gRPC extractors, which walk the repo themselves and previously attributed fixture repos' routes to the host repo.cacheVersionbumped to v135. #146
Running-server instance registry fixes cross-process dashboard and restart state bugs
- FixOne MCP server is started per agent session, so several run at once against a shared
~/.enola, but nothing modelled a running instance, conflating per-process and shared state: the dashboard's PID/uptime/session cards were filled from the cross-process aggregate (a page served by one process could announce another process's PID), the graph panel read the machine-wide receipt while other panels read the live store (describing different codebases on one page), per-repo counter files were overwritten rather than merged (two servers on one repo silently discarded each other's increments), and a restart could come back holding whatever repos another session had snapshotted last. New instance registry under~/.enola/instances/, written by the process it describes and reaped once that PID is gone; counter writes now merge only the unflushed delta under a cross-process lock; graph receipts are additionally written per workspace and preferred on restart. The dashboard now binds a fixed port and lists every live server. #143
Kotlin-on-Maven detection, programmatic servlet routes & Kafka topic cross-repo linking
- AddedKotlin detection only recognized Gradle, so a Kotlin service built with Maven was skipped entirely — no symbols, routes, or client calls extracted. Detection now also reads
pom.xml(kotlin plugin/dependency,src/main/kotlinsourceDir) and falls back to asrc/main/kotlinsource root. Also adds route extraction for the fluent.addServlet("/path", handler)registration DSL, emitted with a newfacts.MethodAny("*") method that the cross-repo HTTP linker treats as a wildcard matched by any concrete-verb client call. #140 - AddedAsync coupling via message topics was invisible — a service consuming another's events showed no dependency. New topic fact (
KindStorage,storage_kind=topic) extracted from an envconfigdefault:tag on a*Topicfield or anenv.Get("..._TOPIC", "default")call, plus alinkKafkacross-repo signal binding consumer to producer via the topic name's owning-service prefix. #141 - ChangedDocumented the Kafka topic signal in
ARCHITECTURE.md's fact model, linker section, and pipeline stage 4 (previously shipped undocumented), backfilled the one-sentence Go entry in Supported languages, and added the golden fixture covering the producer/consumer topic-name join that unit tests of each half individually had missed.cacheVersionbumped to v132 for changelog convention only — Go facts are never cached. #142
Cross-repo shared-symbol linking overhaul: verified matches replace name-only guesses
- FixCross-repo shared-symbol edges were materialized in both directions even when an import or HTTP call had already fixed the direction, so a library whose types a consumer also declares came out depending on that consumer; edges now annotate the existing directional edge instead, staying symmetric only when nothing else fixed a direction. Separately, graph nodes are keyed by name, so same-named facts from different kinds merged and the earliest-stored one supplied the label — meaning
node_kindsfiltering on that label could drop a node from a service-filtered traversal.nodeFornow picks the fact whose kind matches the relation that reached it. #136 - FixGenerated stubs (
.gen/.pb/_pb2/.generated,/mocks/) and unqualified type names shared across more than half of loaded repos were feeding theshared_symbolscross-repo signal, so services generating clients for the same API, or independently modelling the same domain vocabulary, fabricated a cross-repo dependency. Real HTTP/import edges are unaffected. #137 - FixTwo repos declaring the same distinctive type names were linked with a traversable
depends_onedge even though neither imports nor calls the other and the linker has no way to substantiate the claim from names alone — measured across real identities behind these edges, about 45% were entirely unrelated code sharing a name, anddepends_onalso composes across hops while shared code does not, so a caller of one side of a copy-paste pair appeared to transitively reach the other. Such a pair now yields one symmetric, non-relationalcross_repo_shared_codefact (queryable but never in the traversable graph) plus a separate lower-confidence insight; shared symbols still annotate an edge an import or call already established. Also fixed the direction gate, which enumerated directionalviakinds and omittedgrpc— it now tests for anyviaexceptshared_symbols. #138 - AddedA shared type name only ever meant "both repos declare this," which could equally be copied code or two teams independently naming the same concept alike. The linker now optionally reads source and, for each name-matched candidate, compares the two declaring files by token-set overlap (calibrated against a character diff: line-set comparison disagreed on 4 of 13 sampled declarations, all false negatives, versus token-set agreeing on all 13). Only verified names count toward the link threshold, and the resulting fact keeps both
symbol_countandname_match_countso the gap between shared code and shared vocabulary stays visible; the insight moves to 0.8 confidence when verified. #139
CLI gains --help, --list & --status, plus a read-only live dashboard
- AddedNew
pkg/clirenders the tool catalogue behind--listand a composable help spec behind--help(usage, flags, config path, examples, MCP configuration, build), documenting the previously-undocumentedupgradecommand. Newpkg/statusrecords per-tool usage under~/.enola/usage/, keyed by absolute repo path so counters survive both a restart and deleting.enola/, letting--statusreport uptime, session and lifetime call counts, and an estimate of time/context saved;--status --allbreaks this down per repo. #134 - AddedStarting the MCP server now also serves an auto-refreshing read-only dashboard on a free loopback port (
--no-dashboardto opt out;--statusprints its URL). Shows the same activity/value data as--statusplus snapshot and graph receipts, the service and cross-repo-edge lists with a node-link diagram, insights grouped by explainer and filterable by confidence, and the extraction-quality proof (coverage, unresolved routes, skipped-file and parse-error samples). Every request reads through the same concurrency-safe accessors the MCP tools use, and each source degrades to an explanatory note rather than an error page. #135
Cross-repo linker stops false-linking on Rails boilerplate, Swift XcodeGen app targets sub-divided, graph restore-on-restart
- FixTwo repos declaring the same Rails framework-convention classes (
ApplicationController,ApplicationRecord,ApplicationJob,ApplicationMailer,ApplicationHelper,ApplicationCable::Connection/::Channel, CanCanCan'sAbility) or auto-generated Rails migration classes underdb/migrate/were linked as sharing code by the cross-reposhared_symbolssignal, even though every app of that framework declares these identically and every app that ran the same migration gets a coinciding class name. Both are now excluded from the shared-symbol identity check. #131 - AddedThe Swift extractor mapped every file under an XcodeGen application/app-extension target's source root to one module identity, so a target spanning many directories collapsed the whole app into a single module in
package_metrics(hundreds of types, no internal structure) — only SPM-styleSources/<Target>layouts appeared split. Application and app-extension targets are now sub-divided by leaf directory (matching Go/Ruby), with the type-reference pass run intra-target so per-directory packages get real directory-to-directory coupling; framework and SPM targets stay whole.cacheVersionbumped v130 → v131. #132 - AddedThe MCP server dropped its in-memory graph on restart:
AutoLoadSnapshotreloaded only the configured repo'sfacts.jsonl, skipping insights and snapshot metadata, so multi-repo graphs had to be regenerated every restart with nogenerated_atto judge freshness against. Restore now rebuilds the whole graph from disk with no extractor runs, via a graph-wide registry (~/.enola/receipt.json) read byengine.LoadGlobalReceiptand republished byengine.RestoreFromDir; adds warn-only staleness surfacing (graph older than 24h, or a repo's git HEAD moved / working tree turned dirty since its snapshot) prepended to read-tool results. #133
TypeScript HTTP-client precision fixes & Axum nested-router prefix composition
- FixFour precision fixes in the TypeScript http-client extractor: a left word-boundary on the
fetch()/makeRequest()matcher so a call merely ending in "fetch" (router.prefetch(),query.refetch()) is no longer captured as an outbound HTTP call; an options-objecturl:must now carry a real HTTP verb or request-payload key, not just a non-verbtype:(SEO metadata, JSON-LD); a required leading "/" incleanTSPathso non-path string literals don't become phantom routes; and a stripped query-string placeholder fused to a path's final segment.cacheVersionbumped to v128. #128 - FixA client call written as
${this.basePath}/calculate, wherebasePathis a "/"-rooted string literal in the same file, previously lost its base —cleanTSPathstripped the leading${...}token, leaving a single-segment suffix the cross-repo matcher skips. A per-file identifier-to-literal base map now reconstructs the full path so these calls resolve to their server route; conflicting bindings are left unresolved.cacheVersionbumped to v129. #129 - FixThe Rust Axum extractor emitted routes at their bare in-router path and skipped
.nest(...), so a router built by one function and mounted by a parent via.nest("/api/v1/datasets", module::router())in another file was stored as/statusinstead of the true runtime/api/v1/datasets/status, breaking cross-repo client-route matching. A crate-wide fixpoint now resolves each nest to its callee builder and propagates mount prefixes from root builders, mirroring the Go gorilla/mux+chi subrouter composition shipped in v0.1.38.cacheVersionbumped to v130. #130
Go route-prefix composition, struct-usage edges & Rails route-extraction fixes
- AddedNew optional
config.Config.ChangeVerifyHint(set programmatically before server construction, not read from YAML) appended to both the server's Instructions block and the post-snapshotloopHintnudge, giving a host or wrapper that registers extra change-verification tools a way to surface them from the workflow that pulls agents todiff_snapshot. Empty by default, so OSS behavior is unchanged. #123 - FixThe Go route extractor composed gorilla/mux and chi subrouter
PathPrefixmounts only within a single function, so a subrouter created in one place and passed into a per-file/per-package registration function or method that registers bare leaf paths on it started with an empty prefix map — storing a route as/thingsinstead of the true runtime/api/things, skewing cross-repo client-to-route matching. NewbuildRoutePrefixIndexmodule-wide fixpoint propagates each router argument's prefix to the callee's router parameter, also handling receiver-method registrations via the extractor's shared type resolver.cacheVersionbumped v124 → v125. #124 - FixThree further Go-extractor accuracy fixes. A collection-root route registered as
sub.HandleFunc("", h)was dropped because the empty path was checked before composing the subrouter prefix; an empty string literal is now distinguished from a dynamic arg, composing to the prefix instead of being discarded. A struct used as a composite literal or as another struct's field type now emits aRelInstantiatesedge, so an internal type referenced only that way no longer reads as dead code.scaling_loop_depthnow discounts hierarchical nested loops (a loop reached through an enclosing loop variable, e.g.range pkg.Files) so a tree/AST walk is no longer over-reported as O(n²+).cacheVersionbumped v125 → v126. #125 - FixThe new
RelInstantiatesstruct-usage edges (above) kept ubiquitous data structs out of the dead-code report, but a data struct built at many sites is not a god class or call-graph hotspot, and those edges started surfacing ubiquitous data types as false-positive high-fan-in findings.RelInstantiatesis now filtered out ofGraph.ArchitecturalReverse, the shared fan-in helper used only by the god-class and hotspots explainers; traversal,impact_analysis,find_path, and the dead-code detector are unaffected. Explainer-only change, no cache impact. #126 - FixThree Rails route-extraction fixes. A nested plural
resourceswithshallow: truenow emits its member routes (show/edit/update/destroy) at the shallow path while collection routes (index/create/new) stay nested, matching Rails; an optional path segment likefoo(/:bar)now expands to both paths it actually serves instead of one literal that matched nothing; and the hash-rocket route formget 'path' => 'ctrl#action'is now extracted, where previously the path was skipped entirely.cacheVersionbumped v126 → v127. #127
I/O metadata for Rust, PHP & C++, module-graph fixes for nested layouts, SvelteKit route entry points & concurrency-safe snapshots
- Added
pkg/factsre-exportsNewStore,NewGraph, and theGraph/ImpactResult/TraversalNodetype aliases, so out-of-module consumers can reconstruct an in-memory store and dependency graph from a loaded snapshot (e.g. facts read back from a persisted baseline) without importing the internal package. #112 - ChangedScrubbed a real customer repository's name from source comments and test fixtures across the test-path, god-class, surface, and diff explainers, replacing it with anonymized examples. No functional change. #113
- Fix
BuildModuleGraphderived a dependency edge's source module from the file's leaf directory. In nested module layouts — an Xcode/SPM target with files underSources/<Target>/<Sub>/— the leaf directory is not a module, so source nodes never matched module-name targets and no strongly-connected component could form, silently zeroing cycle detection (and skewing dependency-depth) on such projects. NewnearestModulehelper walks a file's directory up to its enclosing production/test module for the edge source; a no-op on flat layouts (Go packages, directory-based Ruby/TypeScript modules). #114 - FixCompanion to the edge-source fix:
BuildModuleGraphand the layers explainer matched an import edge's target against an exact module name, but extractors emit targets at differing granularity — a bare module dir (Go/Ruby), a module dir plus imported symbol (Kotlin/Javaimport a.b.C→a/b/C), or a file stem (TypeScript) — so an exact match dropped every class- or file-suffixed target; on a Kotlin codebase this hid all Kotlin-sourced layer violations and dependency cycles. Target resolution now walks up to the nearest enclosing module in bothBuildModuleGraphand the layers explainer'sdetectViolations. #115 - FixThe Svelte extractor only ever fed a SFC's
<script>block to the parser — the template/markup was discarded, so any handler, action, or binding wired solely from markup (on:click={fn},{fn(x)},bind:x={fn},use:action) had zero incoming edges and was mis-reported as dead code byfind_orphans. NewextractSvelteMarkupRefsscans the markup for such identifiers and folds them into aKindFileReffact, mirroring the TypeScript extractor's JSX file-ref pass.cacheVersionbumped to v120. #116 - AddedThe Rust extractor emitted only cyclomatic complexity, unlike every other extractor, so downstream performance analysis produced nothing for Rust regardless of loop nesting or I/O. The AST walker now tracks syntactic loop nesting with a constant-trip-vs-scaling distinction, records
loop_count/calls_in_loop/calls_in_scaling_loop, flagsrecursive_self, and detects direct filesystem/DB/HTTP primitives asio_direct, with a transitiveperforms_iofixpoint over the call graph ported from the Python extractor. #117 - AddedThe PHP extractor emitted loop metadata but no I/O signal, so an in-loop call to a DB/HTTP wrapper was invisible to N+1 detection — only a keyword name match could fire, under-detecting wrappers and over-firing on in-memory helpers. The walker now flags
io_directon direct filesystem/DB/HTTP primitives (file_get_contents/fopen/curl_exec/mysqli_*/wp_remote_*, and distinctive$wpdb->get_results/get_row/get_var/get_col, PDO/mysqli fetch/query/execute calls) and runs the same transitiveperforms_iofixpoint as the Python/Rust extractors. #118 - ChangedRemoved developer-machine-only C++ integration tests that referenced absolute local paths and would never run in CI, and anonymized a further customer name reference in a Python extractor test comment. No functional change. #119
- AddedThe C++ extractor emitted loop metadata but no I/O signal, so an in-loop file/socket call was invisible to N+1 detection. The walker now flags
io_directon a deliberately narrow set of direct file/socket data-transfer primitives called as free/namespaced functions (fopen/freopen/fread/fwrite/socket/recvfrom/sendto) and runs the same transitiveperforms_iofixpoint; console/logging calls and ambiguous socket verbs (bind,connect,send) are excluded to avoid mass false positives, and<fstream>stream I/O via member calls is not detectable. #120 - FixSvelteKit's file/export-name-convention entry points —
loadin+page.ts/+layout.ts/+page.server.ts/+layout.server.tsunderroutes/, HTTP-method exports in+server.ts, andhandle/handleError/handleFetchinhooks.server.ts— got no route fact and no symbol classification, sincedetectSvelteKitRouteonly ever ran for.svelteSFCs; these plain-.tsframework entry points had zero incoming edges and their exports read asfind_orphansfalse positives.cacheVersionbumped to v124. #121 - FixTool handlers dispatch on their own goroutines, unserialized, so multiple handlers could run against the one shared engine at once.
GenerateSnapshotmutated the fact store in place (Clear → Add → BuildGraph) while readers took no lock, racing the snapshot pointer andrepoPathsmap — andsnapshot.Factsaliases the store's live slice, so a reader could iterate an array being reallocated mid-regeneration. Snapshot state is now published as an immutable bundle behind anatomic.Pointer;GenerateSnapshotbuilds a new store off to the side and swaps the pointer once at the end. Added-racestress tests covering overlapping generates against lock-free readers. #122
Fixed dead-code false positives from macro-defined C/C++ functions, Python closures & guarded imports
- FixA C/C++ function defined through a name-carrying macro the grammar can't expand parsed either as an errored statement with a detached top-level body, or as a clean
function_definitionwhose declarator lacked afunction_declarator— both cases dropped the body's calls, so a static helper reached only from it had zero incoming references and was flagged dead code. NewhandleDetachedBodycredits the body's calls to the module owner, the same mechanism already used for#definereplacement lists; no symbol is emitted for the macro-defined function itself.cacheVersionbumped to v116. #109 - FixPython's
walkForCallshard-stopped at nestedclass_definition/function_definition/decorated_definitionnodes, so a closure's body — every call, decorator, lazy import, and reference inside it — was never walked. Any module-level helper reached only from a closure (router factories, decorator factories, event-listener registrations) had zero incoming references, the single largest false-positive bucket in the Python corpus (~25%). NewwalkNestedScopewalks nested definitions in a shadowed scope that credits references to the enclosing symbol, with metrics suppressed and bound names extended both directions to prevent fabricated edges.cacheVersionbumped to v117. #110 - FixPython imports nested inside a module-level
try/ifblock — the try/exceptImportErrordual-import pattern,if __name__ == "__main__":imports,if TYPE_CHECKING:imports — registered no binding and no dependency fact, so calls through the guarded name were unresolvable and mis-reported as dead code. These statements now route throughregisterBodyImports, with animportFallbackflag so an except-branch import can't clobber the try-branch binding. Conditionally-guarded definitions are deliberately still not emitted as symbols, to avoid manufacturing a dead-code false positive for an intentional shim.cacheVersionbumped to v119. #111
Rust language extractor & Java SPI dead-code fix
- AddedFirst Rust extractor — tree-sitter-based symbols for
fn/struct/enum/trait/type/const/staticwithimpl/traitand nestedmod {}qualification,impl Trait for Typeas animplementsedge,use-baseddependencyfacts classified internal/external/stdlib with cross-crate and submodule resolution for Cargo workspaces,calls/instantiatesedges, per-function cyclomatic complexity, and Axum route detection (including chained verbs likeget(a).post(b)). Tested on dbt-core and Axum. #85 - FixJava classes loaded only by name — SPI implementations registered in a service file, or classes discovered by classpath scanning for a plugin annotation — produced neither a call edge nor a prop and were reported as dead code. Service files are now read and resolved to their in-repo canonical type, emitting a
KindFileReffact the orphan detector already folds in as a reference; classpath-scanned plugin classes are now taggedscanned_pluginas entry points. #108
Test-path gating for architecture explainers, findings-diff dedup & TypeScript ORM storage
- Fixgod-class and hotspots ranked symbols by fan-in with no notion of a test path, so a test-scaffolding assertion helper could outrank real findings. A shared
facts.IsTestPathnow gates the candidate (not the edges) across Swift, Kotlin/Java, Python, and C/C++ — languages the prior reference-only-kind filter never covered. #102 - Fixenola-enterprise's
analyze_performancecould summarize the unfiltered result set instead of the filtered one — apackage="androidTest"filter printed the repo-wide total above an empty table. Newmcputil.Scoped[T]carries the filtered set plus its population size so a renderer has nothing else to count;traverse's node-count stat is fixed the same way. #103 - Fixcycles and dependency-depth only excluded test modules via
module_role == ModuleRoleTest, a prop only Java, Kotlin, Ruby, and Swift emit — Go, Python, TypeScript, PHP, and C/C++ test trees walked straight into the coupling graph. Both explainers, and exported-surface's public-API count, now fall back tofacts.IsTestPathwhere the build-file prop is absent. #104 - Fixenola-enterprise's performance analyzer carried its own weaker glob matcher that silently matched nothing against any
**pattern. The engine's directory-scoped**-capable matcher is now exported aspkg/facts.MatchGlob/MatchAnyGlobso both sides share one implementation. #105 - Fix
diff_snapshotcould report "no facts, edges, or findings altered" whileinsights.json's hash had changed — a findings key collapsed distinct findings (e.g. "5621 more" vs. "3662 more" dead-code candidates) into one bucket with no changed-content case to land in, and 78 same-titled layer violations shared a map key so all but the last were silently dropped from the diff. Findings are now grouped and paired positionally, matching the facts side; a pinned baseline older than 3 days now warns. #106 - AddedTypeScript now emits storage facts — the only backend language in enola that modelled no tables. Covers TypeORM (
@Entity()), Drizzle (pgTable(...)), and Prisma (schema.prismamodels), gated on the package.json dependency.performs_ionow also propagates through repository-wrapper methods around ORM query calls, so a per-iteration call to a wrapper is caught as a candidate N+1. #107 - AddedCode of Conduct and contributing guidelines. #101
Loop-depth parity for Java/C/C++/PHP, Swift override & #if fixes, Flask route detection
- ChangedJava (v104), C/C++ (v105), and PHP (v106) now emit
scaling_loop_depthandcalls_in_scaling_loopvia the same three-valued loop classifier (constant/infinite/scaling) as Go/Python/TS/Kotlin — a literal-bounded loop no longer inflates the reported Big-O exponent. #95 - Fix
query_insights,query_facts(kind=service), and thellm_contextrenderer reported "cross-repo explainer did not run" identically to "found nothing," and misattributed which explainers actually produced an insight. The no-match case now distinguishes a single-repo snapshot (no cross-repo linker output) from a genuine no-match, and lists sources fromInsight.Sourceinstead of the ran-without-error set. #96 - FixSwift methods carrying the
overridemodifier are now taggedoverride:true(v107), matching the existing Kotlin prop — an override is dispatched by its supertype (UIKit/SwiftUI lifecycle callbacks,XCTestCasehooks) and was being flagged dead by the orphan detector, which already consumed this prop for Kotlin but Swift never set it. #97 - FixA Swift type declared in both branches of a
#if/#elseblock produced two same-name symbol facts. Branch symbols are now tagged conditional so counting analyzers collapse the duplicate while keeping genuine overloads intact. #98 - FixPython route detection matched only FastAPI verb decorators and hardcoded
framework:"fastapi"on every match —@app.route/@bp.routeand Flask-AppBuilder@exposeproduced zero route facts (0 of 293 on python/superset). Flask idioms are now detected and taggedframework:"flask"(v109), with the verb-shorthand label derived from the detected framework instead of a literal. #99 - AddedJava now emits
io_direct/performs_io(v110) from high-precision, type-level signals — every method of a@FeignClientinterface or Spring Data repository, any@Query/@Modifying/@Proceduremethod, and Room ops inside a@Daointerface — so a per-iteration repository call can be recognized as a confirmed N+1 instead of ranked on a keyword guess. Deliberately excludes bare@GET/@GetMapping, which are inbound handlers, not outbound I/O. #100
Test-ref parity for Go & TypeScript, Python shadow-guard fix & LLM-context repair
- Fix
llm_context.md's Extraction Quality block only reported unresolved-edge counts whencoverage_gaps > 0, hiding real signal on snapshots with many unresolved calls but zero gaps; the Repository Map section could also consume the entire token budget on multi-repo snapshots and drop the quality preface entirely. Sections now reserve budget ahead of layout, and a byte- vs. rune-boundary truncation bug that could emit invalid UTF-8 is fixed. #87 - FixPython call resolution checked same-class method/property matches before the local-binding shadow guard, so the common
self.x = xconstructor idiom produced a falsecallsedge whenever the class also defined a property literally namedx(e.g. a SQLAlchemy synonym property). The shadow guard now gates every resolution branch, not just the same-module fallback. #86 - ChangedKotlin now emits
scaling_loop_depthandcalls_in_scaling_loopalongsideloop_depth, joining the Go/Python/TypeScript convention — a constant-count loop no longer inflates the reported Big-O exponent. The same fix separates "grows with input" from "has a non-constant trip count" across all four languages, so an infinite loop with a chain walk inside it is now caught as an N+1 candidate instead of masked. #88 - FixGo functions called only from their own
_test.gofile were reported as high-confidence dead code — test files are now scanned for reference-onlytest_reffacts, matching the existing Ruby handling. #89 - FixA Go
baseURL + "/path"HTTP-client pattern discarded the host, causing third-party API calls to be counted as unresolved internal edges and flip isolated backends to a falsecoverage_gap. The base identifier now resolves through a package-scoped string-literal index and is tagged external when every binding is an absolutehttp(s)URL. #90 - FixA Swift type reached only through its
typealiasname was reported as dead code —handleTypeAliasnow folds the aliased type in as aninstantiatesedge. #91 - Fix
test_refandfile_refnodes were indexed into the coupling graph, so their edges landed in the reverse adjacency map and inflated fan-in and centrality for the god-class and hotspots explainers — drifting their outlier thresholds and dropping genuine findings below the cut. Both explainers now read from a filtered reverse index that excludes reference-only edges. #92 - FixTypeScript was the last extractor without
test_refsupport — a production symbol called only from its.test.ts(x)/.spec.ts(x)file was reported dead. Closed to parity with Go and Ruby. #93 - ChangedDoc comments referenced an
unmatched_reasonvalue (no_match) the cross-repo linker never actually emits; the four reason strings are now exportedcrossrepo.Reason*constants so the list is defined once. #94
Cross-repo linking, diff-pairing determinism & skip-accounting fixes
- Fix
diff_snapshotno longer drops or misreports facts that share an identical key (e.g. Swift types declared under mutually exclusive#if/#elsebranches, overloaded methods) — colliding facts are now bucketed, sorted deterministically, and paired positionally instead of last-write-wins, fixing both silently-dropped deletions and spurious changed-fact reports on byte-identical fact sets. #81 - FixCross-repo shared-symbol linking no longer treats nested type names (e.g. Kotlin/Swift
Outer.Inner) as namespace-qualified — only::now counts. Dotted nested names were bypassing the same-language guard and producing spurious bidirectionaldepends_onedges between unrelated repos that merely reused a type name. #82 - FixCoverage-gap detection consolidated behind a single rule — the snapshot receipt was flagging any unresolved outbound call as a gap, while
coverage_reportand the coverage explainer required zero resolved edges, so the receipt's gap count saturated and lost its signal on healthy repos. All three now classify through one shared function. #83 - FixSnapshot receipts now count pruned directories — a directory dropped by an ignore glob (e.g.
node_modules/) previously left its files uncounted entirely, understatingfiles_skippedby orders of magnitude on trees with large ignored directories. A newdirs_skippedfield is added, and each skip sample now names the glob that matched it. #84
Python value-ref resolution, relation-kind breakdown & Ruby test-file scoping
- FixPython call resolution now emits a value-ref edge when a function is passed on via assignment or return (
cb = handler; return cb) — a common factory/registry/DI-by-return pattern that previously left the referenced function with no incoming edge and misreported it as dead code. #79 - FixThe Python call-resolution shadow guard now covers every name bound anywhere in a function's scope — not just parameters — so a loop variable, local assignment,
with ... asalias, or walrus binding that happens to share a name with an unrelated top-level function no longer gets credited as a call to it. #79 - Added
--explainnow reports a relation-kind count breakdown (declares/imports/calls/.../has_method) for visibility into a snapshot's edge mix. #79 - FixRuby test-file matching now requires both a
_spec.rb/_test.rbfilename and aspec/ortest/directory segment — a filename-only rule was silently excluding production files that merely end in the token_test(e.g. anab_test.rbA/B-test job) from indexing entirely. #80
Cross-repo coverage triage, Swift/Rails route fixes & Rails explainer tuning
- AddedCross-repo coverage now buckets calls to hardcoded third-party hosts as
external, separate from genuine unresolved internal gaps; unresolved client calls are tagged with a reason (method_mismatch,path_unknown,generic_path) so the residual can be triaged from data. #77 - FixSwift HTTP method inference widened — multi-line case-label lists and single-value
methodproperties no longer silently default to GET;URLSessionverb detection now scans a symmetric window and recognizes enum/.rawValueforms. #77 - FixRails route extraction now covers PUT on resource updates, symbol-path routes (
get :cities_by_zip), barescopesymbol prefixes, andresource(s) path:overrides — on a 3-repo test graph, unresolved client calls dropped from 97 to 59. #77 - ChangedRuby/Rails architecture explainers tuned to cut false positives — two-tier layering (delivery vs. domain), deduplicated god-class/hotspot candidates, oversized dependency clusters reported as one low-confidence note instead of a cyclic-dependency alarm, and framework scaffolding excluded from god-class and hotspot findings. #78
Graph-wide receipt for multi-repo snapshots
- AddedGlobal receipt written to
~/.enola/receipt.json— describes the current multi-repo graph of graphs: composing repositories, each repo's git commit/ref/dirty state, membership duration, and graph-wide stats (fact/insight counts, cross-repo services and edges, coverage). Complements the existing per-reporeceipt.json. #76
Self-update via enola upgrade
- Added
enola upgradecommand — resolves the latest GitHub release, downloads the matching platform artifact, verifies its checksum, and atomically replaces the running binary. #75
Python gRPC support & call-resolution fixes
- AddedFirst-class Python gRPC support — servicer-to-route binding via the
Servicersubclass convention and client detection fromstub.Method()calls (including positional rebinding of a stub variable across services), bringing Python to parity with the existing Go and TypeScript gRPC support. #74 - FixPython
resolveCallno longer fabricates same-module call edges for callable parameters, locals, and loop variables — removes spurious "used" signals, inflated fan-out, and misleading impact analysis. #74 - FixModule-graph construction no longer misclassifies single-segment internal modules (e.g. top-level
cmd,config) as external, which was silently dropping real dependency edges. #74
Explainer correctness & determinism fixes
- FixMetric parsing in
pkg/explaincorrected — digits inside symbol or module names (e.g.Sha256Hash,oauth2) no longer corrupt parsed fan-in/fan-out and exported-surface percentages. #73 - FixCycle detection made deterministic — cycle paths, evidence order, and insight order no longer depend on map iteration order, via a shared strongly-connected-components pass. #73
- FixDependency-depth explainer no longer under-counts chains hidden behind a cycle — depth is now computed over the SCC condensation instead of a globally memoized, back-edge-truncated walk. #73
- FixCross-repo explainer no longer drops the
viadetail after afacts.jsonlround-trip. #73 - FixLayer explainer hardened — deterministic pattern-insight evidence ordering, layer violations deduplicated by source/target module, and relative import targets (
./x,../y) resolved before layer matching. #73
Python dead-code false-positive fixes & complexity signal improvements
- FixPython dead-code false positives reduced — absolute intra-project imports, function-local (lazy) imports, decorator applications and arguments, parameter defaults, and functions passed by name are now all tracked as call/reference edges. #72
- Added
scaling_loop_depthandcalls_in_scaling_loopsignals — loops over constant/bounded ranges no longer inflate scaling-complexity or N+1 findings the way input-scaling loops do. #72 - AddedPython
performs_iopropagation — direct DB/network/file calls now propagate transitively across the call graph, matching the existing TypeScript pass. #72 - AddedPython extractor now classifies enums, data classes (
@dataclass, attrs, Pydantic models,NamedTuple,TypedDict), and duck-typed abstract classes (methods that only raiseNotImplementedError). #72 - FixPython virtualenvs and installed dependencies (
.venv,site-packages,.tox, and similar) are no longer walked, parsed, or cached, cutting snapshot time on repos with a local virtualenv. #72 - ChangedGenerated/vendored path filtering widened to cover
vendor/,openapi-gen/,third_party/,__generated__, and common codegen suffixes, keeping them out of performance analysis. #72
Gemfile-less Ruby repo detection
- AddedGemfile-less Ruby repositories now detected, with extensionless Ruby executables indexed via shebang detection. #71
gRPC support & expanded cacheVersion test coverage
- AddedgRPC support — proto RPCs modeled as routes, with Go server handlers bound via
handled_byedges; grpc-go, connect-go, and struct-field-injected Go clients now detected as client-role routes. #70 - AddedTypeScript grpc-web and connect-es client call sites now recognized alongside Go clients. #70
- ChangedTest coverage extended — every
cacheVersioncase now covered by unit and golden tests, with an enforced coverage guard. #69
CLI version flag
- Added
--versionflag on theenolabinary, printing the ldflags-stamped build version. #68
Snapshot receipts
- AddedSnapshot receipts —
receipt.jsonnow captures provenance (version, git ref, content fingerprint, config hash, output hash) and extraction-quality metrics; newsnapshot_receiptandcompare_receiptsMCP tools added, with quality signals surfaced inllm_context. #67 - Added
diff_snapshotcomparability guard validates snapshot compatibility before comparison. #67 - FixSnapshot generation now requires an explicit
fresh=trueparameter, preventing silent data pollution when switching between projects. #67
Expanded cross-repo HTTP-client detection
- ChangedHTTP-client detection expanded — TypeScript options-object and
openapi-fetchpatterns, Swift endpoint enums with prefix resolution (plus request-wrapper and stored-method endpoint resolution), and Railsdraw()scope prefixes now recognized. #66 - FixRails nested singular/plural resource route path handling corrected; import/shared-symbol false positives removed from cross-repo HTTP-client detection. #66
TypeScript & Kotlin extractor accuracy improvements
- FixTypeScript dead-code false positives reduced — JSX, import, and
this-member references now tracked; framework-dispatched symbols correctly excluded from unused-code findings. #65 - PerfMinified bundle files now skipped during performance analysis — transitive
performs_iopropagation added to cut false positives. #65 - FixTypeScript interfaces now classified as data shapes and excluded from package metrics, at parity with type aliases; abstract classes properly tagged. #65
- FixKotlin dead-code false positives reduced — method kinds and call edge types (receiver, property, callable-reference) now emitted; framework, DI, override, and test entry points excluded from orphan detection. #64
- FixKotlin cross-module Groovy-namespace imports now resolved correctly in multi-module projects. #64
TypeScript monorepo aliases, Swift accuracy & Ruby on Rails graph
- FixTypeScript monorepo path aliases now resolve correctly — a root
tsconfig.jsonwithout aliases no longer blocks alias discovery in nested packages;~/and#/imports resolve against the correct package config. #61 - FixBarrel re-exports (
export * from,export { X } from) now tracked as dependency facts — code accessible only through index files no longer appears unused. #61 - AddedPlain JavaScript framework detection — Vue, React, Svelte, Next.js, and Nuxt projects without TypeScript are now correctly identified. #61
- FixSwift extractor accuracy improved — false positive count in performance analysis reduced from 259 to 81 via bounded loop detection and recursion awareness; dead-code detector no longer flags methods in flattened types. #62
- ChangedSwift modules now tagged with roles (production / test / tooling); test bundles collapsed and excluded from the dependency graph. #62
- ChangedRuby on Rails graph improved — dead-code coverage extended to delegate, ivar, and string-dispatch call sites; mixin vs namespace module classification added with namespace-aware coupling resolution. #59
C/C++ macro parsing & Ruby dynamic dispatch
- ChangedC and C++ extractors improved — macro parsing, function pointer detection, field assignments, compound literals, and token pasting now handled; false positive rate reduced significantly. #57
- ChangedRuby extractor extended — callbacks, dynamic dispatch, chained no-arg calls,
.rakefile indexing, and predicate (method?) and bang (method!) style methods now captured. #58
Diff tool, C support & extractor improvements
- AddedArchitecture diff tool — compare two snapshots to detect structural changes between commits or branches. #56
- AddedC language extractor — first-class support for C source files alongside the existing C++ extractor. #56
- ChangedRuby on Rails graph improved — more accurate dependency extraction across Rails projects. #56
- FixC++ extractor bug fixed and snapshot comparison hardened. #56
Svelte support, shared MCP result helpers & snapshot refresh fix
- AddedSvelte framework detection — Svelte components and SvelteKit routing now extracted and linked in the architectural graph; the
$libalias resolves tosrc/lib/for correct internal dependency edges. #51 - ChangedMCP result-builder and output-cap helpers (
jsonResult,textResult, token-budget capping) extracted topkg/mcputilso out-of-module tools share one implementation instead of duplicating it. #52 - Fix
query_insights'repofilter matched by loose substring against the insight title, so filtering byrepo="golf"could also return insights from unrelated sibling repos that merely share the token (golf-ui,my-golf-journal-ios). The filter now matches against the evidence path's repo segment on multi-repo snapshots, falling back to the title-substring heuristic only when a snapshot has no repo prefixes to match against. #53 - FixAuto-loaded facts no longer overwritten on snapshot refresh —
appendflag now handled correctly during cleanup. #54
JS/JSX support, query_insights tool & doc fixes
- Added
query_insightstool — surfaces architectural insights including dead-route detection directly from queries. #50 - AddedTypeScript extractor now recognises
.jsand.jsxfiles — mixed JavaScript/TypeScript codebases are fully indexed. #49 - FixDocumentation corrections across tool descriptions and cross-repo capability wording. #50
Unused route detection in cross-repo environments & extended testing
- ChangedTesting strategy extended with broader coverage and linting fixes applied across the codebase. #47
- AddedServer-side inverse computation — unused routes are now identified across multi-repository graphs, surfacing dead endpoints that no client calls. #48
- ChangedCross-repo capability descriptions updated to reflect the expanded route analysis. #48
Coverage insights, HTTP client extraction & extended testing
- AddedHTTP outbound client extraction for Ruby (
Net::HTTP,Faraday,HTTParty) — outbound API calls now appear as graph edges in Ruby projects. #45 - FixGo HTTP client extraction improved — more call patterns captured for outbound API edges. #45
- AddedCoverage insights — unresolved dependency counts now tracked for Swift, Kotlin, Go, and TypeScript extractors. #46
- FixJava and Python HTTP client extraction hardened with improved exception handling. #46
- FixCI configuration corrected — coverage explainer source files no longer excluded by
.gitignore. #46
Extraction pipeline performance pass
- PerfScanning performance improved — first pass of optimisations across the extraction pipeline. #44
Security patch — CVE-2026-27896
- FixUpgraded
modelcontextprotocol/go-sdkto 1.3.1 to remediate CVE-2026-27896 (improper handling of case sensitivity). #43
Vue & Nuxt support, per-function complexity metadata & Python polyglot detection
- AddedVue and Nuxt framework detection — Vue SFC script block extraction, composable detection, and automatic Nuxt file-based route inference (e.g.
pages/users/[id].vue→/users/[id]). #40 - AddedPer-function metadata — complexity and performance metrics now tracked for Go, Python, Ruby, Swift, Kotlin, TypeScript, and Java functions. #41
- FixKeyword matching and nested closure handling corrected across multiple extractors. #41
- FixDeep-nested project detection extended to Python repositories —
pyproject.toml,setup.py,setup.cfg, andrequirements.txtnow trigger deeper TypeScript root search in polyglot repos. #39
Adaptive TypeScript project detection & Python symbol indexing
- ChangedTypeScript root detection now adapts search depth to repository structure — simple repos search 2 levels, Java/Gradle polyglot repos search up to 8 levels to find nested frontends (e.g.
src/main/resources/ui). #38 - AddedJava project detection (
pom.xml,build.gradle,build.gradle.kts) to trigger deeper TypeScript search in polyglot repositories. #38 - FixCommon build and dependency directories (
node_modules,dist,.next,build,out,target,vendor) now skipped during TypeScript root search — reduces false positives and improves performance. #38 - ChangedPython extractor enhanced with two-pass symbol indexing — cross-file type resolution, typed parameter inference, and abstract-to-concrete implementation tracking. #35
--explain flag & multi-language fixes
- Added
--explainflag — asks enola to explain the architectural reasoning behind query results. #34 - FixRuby dependency resolution improved —
requireandincludestatements now resolve correctly across modules. #37 - FixJava static import detection gap closed. #34
- FixSwift & Kotlin edge dependency resolution corrected; Kotlin internal module coupling now resolves accurately. #34
- ChangedArchitecture detection improved for more accurate pattern recognition. #36
C++, Java & Ruby tree-sitter rewrite
- AddedC++ extractor — classes, functions, and cross-repository edge detection. #31
- AddedJava extractor following the established multi-language extraction pattern. #32
- ChangedRuby extractor migrated from regex to a tree-sitter AST parser — more accurate class, method, and call-site extraction across Rails and plain Ruby projects. #33
Python tree-sitter rewrite
- AddedPython extractor rewritten on tree-sitter AST — adds Django support (models, class-based views, DRF serializers, URL patterns), decorator recognition, and improved call/instantiation edge detection. #28
TypeScript improvements, token economy & release tooling hardening
- ChangedTypeScript extractor improved with React component, hook, and route handler semantic classification. #27
- PerfToken economy improved across all tools — output budgets enforced, cross-repo summaries tightened. #29
- Fix
find_pathimproved — ambiguous node names handled correctly, cross-repo dependency traversal corrected. #29 - ChangedDocumentation restructured — a new
ARCHITECTURE.mdadded, describing enola's fact/relation graph model and its core determinism invariant: every fact comes from a real parser or a deterministic algorithm, never inferred by a language model. #30 - FixRelease workflow and install script hardened — native ARM64 and Intel GitHub Actions runners replace cross-compilation on shared runners, workflow permissions scoped explicitly, and
install.shnow falls back toshasum -a 256whensha256sumisn't available (stock macOS). #26
First official release — Swift AST parser, HTTP client extraction & install script
- AddedSwift extractor upgraded from regex to a C-based AST parser (tree-sitter) — more accurate symbol extraction across SPM and Xcode projects. #24
- AddedHTTP client extraction for Kotlin (Retrofit), Swift (URLSession), and TypeScript (fetch/makeRequest) — outbound API calls now appear as graph edges. #25
- FixCall graph resolution improved across Go, Kotlin, and TypeScript — typed variable method calls and cross-module imports now resolve correctly. #23
- FixInstall script added with checksum verification and OS/arch auto-detection for Linux, macOS, and Windows. #25
Cross-repo resolution fixes & org migration
- FixCross-repository graph resolution corrected — edges between repositories now resolve accurately across all reference patterns. #21
- ChangedArchitectural node descriptions improved for clearer, more consistent output across graph types. #22
- ChangedTop-level inclusion refactored — modules can now be included at the project root without additional configuration. #20
- ChangedReferences migrated to the enola-labs GitHub organisation — all import paths, links, and identifiers updated. #19
Graph of graphs & first-response latency
- AddedGraph of graphs — multiple repository snapshots now connect into a single traversable graph. Cross-repo edges resolve correctly across module and service boundaries. #18
- PerfFirst-response latency reduced — top-level resolution object rebuilt to surface the most relevant context on the initial query without a second round-trip. #17
Rebrand to enola
- ChangedProject renamed from archmcp to enola. All identifiers, config keys, and MCP server registration names updated. Existing
archmcpconfigs require a one-line rename. #11
Kotlin DI detection
- ChangedImproved dependency-injection detection in Kotlin — constructor, field, and setter injection are now captured as first-class graph edges. #10
OpenAPI integration
- AddedOpenAPI spec integration — routes and schemas from
openapi.yamlare merged into the architectural graph. #9
In-memory graph, query performance & Rails support
- AddedIn-memory knowledge graph — the full architectural graph is now held in memory for zero-latency snapshot queries. #6
- AddedRuby on Rails support — routes, controllers, and models are extracted and linked in the graph. #5
- PerfQuery efficiency improvements — snapshot generation is substantially faster on deep dependency graphs. #4