01 · Pipeline
From source file to snapshot
Enola never loads or executes your application. It reads source with tree-sitter grammars and language-specific extractors, and everything after that is plain data. The pipeline is fixed and deterministic — the same commit yields the same snapshot ID, every time.
walk files -> extract facts -> store -> link -> index the graph -> explain -> write .enola/
- Walk. Files are enumerated under the repository and filtered through the ignore globs, so build output, vendored code and generated files never reach a parser. The TypeScript extractor additionally detects minified or bundled files by content — any line longer than roughly 2,000 characters — because a checked-in vendor bundle outside a build directory would otherwise fill the graph with obfuscated symbols.
- Extract. Each extractor first detects whether it applies — the Go extractor runs when there is a go.mod — then parses the files it claims and emits facts. This stage is parsing and nothing else. No inference, no model.
- Store. Facts land in an in-memory store indexed by kind, file, name and repository. In multi-repo mode each fact carries a repository label and paths are prefixed with it.
- Link. Edges no single extractor could see are resolved here, in three ordered steps: pre-link binders, then cross-repo signals (only when two or more repositories are loaded), then post-link binders, which bind routes to their handlers and record which routes went unmatched. The whole stage is recomputed from scratch whenever a repository is appended.
- Index. The bidirectional graph is built — this is what traverse, find_path and impact_analysis walk.
- Explain. All eighteen explainers run on every snapshot and emit findings. They always all run; the policy comes later and only decides which findings may set an exit code.
- Write. Artifacts go to .enola/, including llm_context.md, a token-budgeted summary an agent reads directly. Content hashes make the next run incremental.
Five plugin roles drive the middle of that: extractors (source to facts), binders (resolve references across an assembled fact set), cross-repo signals (evidence that one repository depends on another), explainers (facts to findings) and renderers (snapshot to artifacts). Each is a small Go interface with a registry, so adding a language or an analysis is an addition rather than a change to the engine.
02 · Facts
What a file becomes
A fact is a typed node with a name, a file, a line and outgoing edges. That is the whole vocabulary — everything an explainer checks, and everything an agent queries, is nodes and edges of these types.
A Go file internal/auth/handler.go with a LoginHandler struct, a Verify method on it, and a call to tokens.Decode becomes roughly:
module internal/auth symbol LoginHandler (symbol_kind: struct) declares--> LoginHandler.Verify symbol LoginHandler.Verify (symbol_kind: method) calls-----> tokens.Decode dependency internal/tokens (imported by internal/auth)
Every one of those lines came from parsing the file, not from asking a model what the file looks like. The complete catalogue of kinds and relations is on the Reference page; show_symbol and query_facts return them for a single symbol or file when you want to check a specific one.
03 · Baseline
Why there are two snapshots
A linter answers does the current tree break rules, and one tree is enough for that. Enola answers a different question — what did this change do to the structure — and no single tree can answer it. A repository with 268 existing findings tells you nothing about the change you just made; the one finding that arrived with it does.
So baseline pin stores a snapshot as a value, and check builds a second one and reports the difference. Findings already in the baseline are not your change and are not reported.
Both snapshots carry a receipt: the Enola version, the git ref, whether the tree was dirty, the extractors used, and a snapshot ID that is a sha256 of the facts rather than a random identifier. Before diffing, Enola checks the two were built the same way. If they were not — a different version, a changed ignore rule — the delta would describe how the snapshots were produced rather than what you edited, so the check exits 3 and declines to grade rather than blaming your change for it.
04 · Traced
One regression, end to end
This is examples/layers-gate — five Go packages and one ./run.sh. The module declares a layer order, outermost first:
layers:
- {name: delivery, paths: ["web/**", "notify/**"]}
- {name: api, paths: ["api/**"]}
- {name: storage, paths: ["storage/**"]}A baseline is pinned, and then one function is added to the innermost package — it emails the buyer a receipt, from inside storage:
import "layersgate/notify"
func LoadPrice(item, buyer string) int {
price := ReadPrice(item)
notify.SendReceipt(buyer, item)
return price
}Here is what each stage does with it.
- Extraction records a dependency fact — storage -> layersgate/notify at storage/storage.go:3 — a symbol fact for storage.LoadPrice at line 11, and the call edges out of it.
- The layers explainer reads the declared order, sees the innermost layer importing the outermost one, and emits a finding. It carries confidence 1.00 because the order was declared. An order Enola merely recognised from repository shape caps at 0.80, which is below the default gate floor.
- The delta compares against the pinned baseline. This finding was not there before, so it is reported as introduced rather than as one of the repository's standing findings.
- The policy decides the exit code, and only then. With no --fail-on, the run reports and passes, and says so:
PASS — 1 new finding reported, nothing enforced: no policy set.
New findings (reported — no failure policy set):
- [layers] 1.00 — Layer violation: storage -> delivery
import of notify
No --fail-on policy is set, so nothing in this run could fail the build.Name the rule and the same change stops the build:
FAIL — 1 structural regression introduced.
Regressions (fail):
- [layers] 1.00 — Layer violation: storage -> delivery
import of notify
Policy: fail on new findings from [layers] at confidence >= 1.00.
What changed
symbols +1
dependencies +1
edges +4 (imports +1, calls +2, declares +1)
Added (2):
symbol storage.LoadPrice storage/storage.go:11
dependency storage -> layersgate/notify storage/storage.go:3
New coupling (4):
storage --imports--> notify
storage.LoadPrice --calls--> notify.SendReceipt
storage.LoadPrice --calls--> storage.ReadPrice
storage.LoadPrice --declares--> storage05 · Limits
What it cannot see, and what it does about it
Extraction resolves what it can parse. A URL assembled at runtime, a client library Enola does not know, a framework that registers handlers reflectively — each can leave an edge unresolved. A tool that quietly drops those looks identical to one that found everything.
So Enola counts them instead. enola coverage reports, per service, how many outbound calls were detected, how many were matched to a route, and how many were not — which is the difference between a service that genuinely talks to nothing and a service whose edges Enola failed to follow. Absence of an edge is never proof that no runtime relationship exists, and the output says so rather than implying otherwise.
Confidence works the same way. Only findings Enola computes with certainty reach 1.00 — dependency cycles, a declared layer order, a declared cross-repo seam. Everything else is an estimate measured against your own repository's distribution, reads as unusual for this codebase rather than wrong, and caps below 1.00 by design. That is why the default gate floor excludes them until you lower it deliberately.
The long version of where this fails, written against real repositories and including a bug the exercise found in Enola itself, is docs/BLIND-SPOTS.md. The full engine internals — the fact model, the value model and the tool reference — are in ARCHITECTURE.md.