Component 1 of 3 · the base

One typed graph, from whatever your repository is made of.

Per-language parsers feed a single shared model: Entities (functions, classes, modules, files) joined by typed Relationships (calls, contains, uses-type, imports, implements). Every metric, smell, panel and agent tool anywhere in this project is a question asked of that one graph.

On its own

Nothing above this layer is required.

The engine is two Rust binaries and no service. It reads a directory and writes a graph — to a file, to stdout, to a socket, or to an agent over MCP. The visualizer and the VS Code extension are both clients of what this layer already emits; neither is needed for the analysis itself.

Entry pointWhat it doesWho calls it
nao analyzeOne-shot analysis to JSON, DOT, Mermaid or ASCII.You, a script, a CI job
nao mcpMCP server over stdio — ten read-only tools.A coding agent
nao watchRe-analyses on save; serves the graph API and an event stream.The visualizer, the extension
nao serveSeveral already-analyzed repositories under one API.The visualizer, hosted
elevatorThe same engine pointed at .elv spec files.You, and --check in CI
nao
# One-shot analysis to JSON — no UI involved
nao analyze ./my-project -f json -o analysis.json

# Filter by language, limit traversal depth
nao analyze ./my-project -l rust -l python -d 5

# Pipe a diagram straight into a doc
nao analyze ./my-project -f mermaid > docs/graph.mmd

# Expose the graph to an agent over stdio
nao mcp

nao watch and nao serve exist to feed a renderer, but they are ordinary HTTP servers — anything can read them, and their responses are the same JSON analyze writes.

Measurement

Nine metrics per entity, one score to sort by.

MetricReads as
Cyclomatic complexityIndependent paths through a callable — the classic branch count.
Cognitive complexityHow hard it is to follow, weighting nesting over raw branching.
Max nesting depthThe deepest indentation level reached.
Fan-in / fan-outHow many entities depend on this, and how many it depends on.
Instabilityfan_out / (fan_in + fan_out) — 0 is a stable dependency, 1 is a leaf.
WMCWeighted Methods per Class: the summed complexity a container carries.
Chain depthLongest outbound call chain — flags Law-of-Demeter violations.
PageRankCentrality in the call/use graph: what the codebase actually revolves around.
Composite scoreThe single number that sorts a refactor list.

Signals, not gates

Five smells, computed from metric combinations.

God Class

A container that accumulated everything: too many methods, too much summed complexity, too many collaborators.

Dispatcher

A function that does nothing but route — high fan-out, no depth of its own.

Feature Envy

A method more interested in another class’s data than its own.

Shotgun Surgery

One concept whose every change forces edits across many files.

Data Bag

Fields with no behaviour — flagged separately, because Kotlin data classes and Python dataclasses are the idiomatic form of the same pattern.

Detected in a post-parse pass, then surfaced wherever you are looking: the quality MCP tool, the Quality panel in the visualizer, red rings on the graph. Nothing here fails a build — the ranking exists to point at what to read next.

Coverage

Ten dedicated parsers, in two capability tiers.

The tiers are about what you get, not about first-class versus fallback. The upper tier emits exact UsesType edges from signatures and fields — that is where impact analysis is type-accurate rather than approximate.

Entities, call edges, and exact type edges

RustOptional exact call edges via rust-analyzerPythonTypeScriptJavaScriptJavaKotlinSvelteComponent-level

Entities and call edges, no type edges

GroovySpring beans, embedded scriptsImpexSAP Hybris — hand-rolled parserAnsible / K8sTopology-orientedElevatorThe spec language itself

Everything else — including Go — falls back to a generic parser: entities, but no reliable relationships.

The other binary

elevator: the same engine, pointed one floor up.

.elv spec files are parsed by the same machinery that parses source, which is why --drift can compare the two. It ships alongside nao and is installed by the same command.

The spec language →

For agents

nao mcp is this page, without the reading.

Ten read-only tools over stdio, emitting compact ranked text rather than JSON — the same graph, shaped for a consumer that pays by the token.

The agent loop →

Honest limitations

Where the graph is exact, and where it is a strong lead.

93.7%recall — real dependents found
98.6%precision — of those found, correct

Against a rust-analyzer oracle on nao itself, Rust, tests included, 3,649 exactly-resolved call edges. Re-measurable from the repository with the benchmark script that ships with it.

Read the numbers this way

Call resolution is name-based and locality-ranked, not type-resolved. An empty impact result is strong evidence, not proof: roughly one real dependent in sixteen is still missing, so Used by (0) on something you expected to be called is a reason to go and check.

That measurement is one corpus in one language

Rust only, on nao itself, because rust-analyzer is the only oracle wired up. Assume the other parsers are worse rather than equal.

What is still missed, and why

  • Closure parameters, whose type is the iterator's item type.
  • Locals bound to non-constructor expressions — let c = spawn()?.
  • Chained calls — a().b().

All three need return-type inference, which nothing currently does. What is still mistargeted is a long tail of same-directory same-name ambiguity: locality prefers the nearest candidate, which is not always the one in scope.

Other edges of the envelope

  • Type usage is exact only where UsesType is emitted — Rust, TypeScript, Svelte, Java, Kotlin, Python. Elsewhere impact approximates it via "used via members".
  • The first call after a source change pays a full re-analysis — seconds, growing with repo size. Subsequent calls come from the warm cache in milliseconds.
  • similar is identifier- and signature-based: it finds nameable duplication, not structural clones.
  • Elevator's descriptions and code pointers are authored, so they are the one layer that can lag. --drift verifies the anchors; a description that is wrong without naming anything checkable still needs a reader.