enola/Docs/How it works

Documentation

How Enola works

Every claim on this page is checkable against the source. After reading it you will know the stages between a file on disk and an exit code, what Enola records as a fact and what it refuses to guess, why there are two snapshots rather than one, and why a check is safe to run in a hook or a CI job.

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.

The pipelineflow
walk files -> extract facts -> store -> link -> index the graph -> explain -> write .enola/
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. Index. The bidirectional graph is built — this is what traverse, find_path and impact_analysis walk.
  6. 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.
  7. 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.

enola --explain is the same pipeline with a different exit: stages one through six run normally, then the report is printed and nothing is written. .enola/ is never touched, which is why it is safe to point at a repository you have not decided about yet.

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:

internal/auth/handler.gofacts
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:

enola-intent.yamlyaml
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:

storage/storage.gogo
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.

  1. 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.
  2. 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.
  3. 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.
  4. The policy decides the exit code, and only then. With no --fail-on, the run reports and passes, and says so:
enola checkexit 0
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:

enola check --fail-on=layersexit 1
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--> storage
Note what the toolchain had to say about that import: nothing. go build ./... succeeds, go vet is silent, no test fails, and the file that introduces it reads perfectly well on its own. The defect is the edge, not the line, and the only thing it contradicts is an order written down somewhere else.

05 · 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.

Because it only parses, a check is cheap to run anywhere. Enola never executes your code, boots no framework, needs no database, uses no model and no embeddings, and makes no network request. Nothing about your repository leaves the machine. That is what makes it safe in a pre-commit hook, in CI, or immediately after an AI-assisted change.

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.