Skip to content

Reference

Operational reference for muundo-core, the Python module, muundo and muundo-server. For a first run, see Getting Started; for what Muundo deliberately does not do, see Design decisions.

Languages

Fourteen tree-sitter grammars, thirteen languages — typescript and tsx are separate grammars for the same language:

python  javascript  typescript  tsx  rust  go  java
csharp  c  cpp  ada  kotlin  arkts  yaml

Those are the exact strings the languages option accepts. The authoritative list is what your build reports:

muundo info | jq -r '.data.supported_languages[]'

Ada and C are not decoration. They are what avionics, rail and medical device code is written in, and they are usually the languages an analysis tool skips.

.h is C or C++, and only its content says which

Both languages use it, and it is the most common extension for a C++ header. So muundo decides from the file: a .h containing namespace, a class, or an inline method is parsed as C++; anything ambiguous stays C.

The language filter follows that decision, not the extension. A .h is discovered under --languages c and under --languages cpp alike — at that point nothing has read it — and once the content has settled the question, the filter is applied again to the answer. So a C++ header is in a cpp run and not in a c run, and a plain C header the other way round. Naming either language never means "and some of the other one".

What a report carries

Field What it is
entities Functions, methods, types — with file, line, qualified name, and the parameters a callable declares
call_edges Caller → callee, resolved where the callee is unambiguous. A callee is a NAME — see below
metrics_by_entity Cyclomatic complexity, fan-out, size, per entity
dependencies Module and package edges — with what became of each target
reachable_from_entry What is actually reachable — dead code shows up as absence
fragility, hotspots Composite risk scores over the graph
risk_index A second combined score with its four components published separately — complexity, coupling, cycles, documentation. Absent under the fragility fast plan, which does not measure complexity
doc_coverage, entities[].documented What share carries documentation, and which ones
production, entities[].non_production The same documentation share and risk_index over production code only, and which entities were left out (tests, specs, fixtures, examples) and why
dependency_cycles Each import cycle, by the files that form it
bridge_nodes The joints between subsystems — PyO3 classes and methods bridging Rust and Python
file_hashes, provenance What was read, by which analyzer version, and with which options
config_inputs The tsconfig.json / jsconfig.json files that decided how imports resolved, each with its own hash
config_selections How the nearest one was decided, per source directory: the places that held nothing, and the file that won
skipped_files Files that were never read — parse failure, undecodable bytes, outside the root. Each carries a reason_code
partial_analysis Stages that stopped early on files that WERE read, because a limit was reached
is_complete Both of the above empty. Derived, so you do not reproduce half the rule

What a function accepts, and whether it read it

A Python lambda is an entity named <lambdaN>, N its place among the file's lambdas in source order: a call on the name it is bound to, or on the parameter a caller passed it to, reaches it. It carries no parameters and is not assessed for documentation.

entities[].parameters is the declared parameter names, in source order, for every language muundo reads. The receiver is never in the list: self, this and &self are not parameters.

body_features.parameter_uses is the other half — one entry per named parameter, with the lines where the body reads it. A parameter the body never reads has an entry with an empty use_lines. Absent and empty are different answers on purpose.

for e in report.entities:
    for u in (e.body_features.parameter_uses if e.body_features else []):
        if not u.use_lines:
            print(f"{e.name} takes {u.variable} and never reads it")

A parameter reached only through a field — options.retries — counts as read: the record is of the identifier, not the whole path.

Two empties, two meanings. An empty parameters means muundo did not record any, never that the function takes none; an empty parameter_uses means the pass did not run for that definition. A rule built on either must say nothing when the list is empty, or it flags every function at once.

Which files are analysed

Only ignore rules that travel with the analysed tree are applied; machine-local git rules are not.

The walk honours what is checked out with the tree: .gitignore and .ignore files inside the analysed root, and hidden files are skipped.

It deliberately ignores rules that live on the machine rather than in the repository — the user's global gitignore, .git/info/exclude (which is per clone and never committed), and .gitignore files in directories above the analysed root.

The reason is that a report names the files it read and hashes them, and verify re-reads that list. Both are worthless if the list depends on who ran it. A file the walk never yields is not a file that was skipped, so skipped_files stays empty and the report reads as complete while whole files are missing.

There is no option to turn the local rules back on. It would be an option to make a report unreproducible, and the person who most needs the guarantee is the one least likely to know it had been switched off.

Which parser produced it

analyzer_provenance names the muundo version, the tree-sitter runtime and the grammar version of every language, so two reports can be told apart by what parsed them.

The versions are the ones the build actually resolved — patch level included. That matters: a grammar patch changes what the parser sees, so 0.25.10 and 0.25.11 can disagree about the same file. They used to be reported as 0.25 alike, which made two different answers carry identical evidence.

How the report was made

analyzer_provenance.configuration records the options the analysis actually ran with. Read it before concluding anything from an empty field.

Without it, two reports that say completely different things are the same document. --languages rust on a Python project gives no entities; so does an empty directory. include_call_graph: false gives no call edges; so does a project where nothing calls anything.

Field What it says
languages The filter, lowercased and de-duplicated. Empty means every grammar was tried — not "no language matched"
include_call_graph Whether the edge list was serialized. The graph is always built; false explains an empty call_edges
include_doc_coverage Whether documentation coverage was computed
ts_stateflow_strategy evidence or inversion
plan full or fragility. The fast plan skips call edges, metrics, hotspots and reachability
max_file_size, max_total_bytes, max_file_count The ceilings in force. A file over the first is in skipped_files
containment_root_supplied Whether a containment root was given

The containment root itself is not recorded. It is an absolute path on the machine that ran the analysis, and every other path in the report is made relative precisely so that machine does not travel with the report.

Verifying a report analysed under a wider boundary

A report with containment_root_supplied: true was produced with a containment boundary WIDER than the analysed root — the HTTP server does this, authorising a request against a workspace base and analysing a project beneath it. Import resolution then walks up to that boundary, so reproducing the report faithfully means giving the boundary back.

The report never carries it — it is an absolute path on the analysing machine, and recording it would defeat the point of every other path being relative. So the operator re-supplies it with --workspace-base <DIR>:

muundo verify --report r.json --root ./project --workspace-base /srv/workspace

verify canonicalises the boundary and the root, proves the root is beneath the boundary, and replays the analysis with that boundary in force. It refuses (invalid_root, exit 2) when a report needs a boundary and none was given, or when the given boundary does not contain the root. A report with containment_root_supplied: false ignores the flag and verifies against the analysis root, unchanged.

One name for one analysis

snapshot_id is a BLAKE3 digest of everything that decided the report: the digest of every source file read, of every configuration consumed, the places a configuration had to stay absent, the options the analysis ran with, and the engine and grammar versions that ran them.

It is deliberately NOT a digest of the report. What was found is not in it, so two reports that disagree at the same id are exactly the interesting case. The clock is not in it either, nor the analysed directory's name: the same build over the same bytes gives the same id at any path and at any hour, which is what lets two runs be compared, cached or referred to without carrying the document.

A Git revision is not this. It is context to attach beside it. An uncommitted tree has no revision, and one revision analysed with two option sets is two snapshots. Identity comes from what was read.

verify checks that the id a report carries is the id of that report's own inputs, before it opens anything. An edited manifest and an untouched id no longer agree, and saying so costs no file access.

The root must not change while the analysis runs

muundo proves the analysis root is inside the containment boundary before it opens anything, and proves it is still the same directory immediately before the walk begins. Discovery then walks BY PATH — it hands the walker a path, not an open directory — so the instant between that last proof and the first directory open is not covered. A root replaced in that instant is walked as its replacement.

What still holds, and it is the part that decides what ends up in a report: every file the walk finds is resolved and re-checked against the boundary before it is read. A replacement changes which files are offered, never which ones are accepted. Nothing outside the boundary is read, hashed or reported.

So if the root sits on storage somebody else can change while muundo is running — a network mount, a shared volume, a directory the analysed code can itself write to — keep it unchanged for the duration. Otherwise the set of files considered is whatever the tree happened to be at each moment, even though every file in the report is still one that was inside the boundary.

config = report.provenance.configuration
if config.languages:
    print("only looked at:", config.languages)

In Rust it is report.configuration().

A report that does not carry the block is UNKNOWN, not default. One from a version that pre-dates the field still reads, and the block is absent — report.configuration() in Rust and report.provenance.configuration in Python both answer nothing. Filling it with defaults would answer a question nobody answered: a run made with --languages rust and one made with no filter would both come back saying no filter.

config = report.provenance.configuration
if config is None:
    print("this report does not say how it was made")

Documentation, and cycles, are answers rather than scores

entities[].documented says whether that entity carries a docstring or a doc comment; doc_coverage is the share of assessed entities that do. Both are absent when the analysis ran with include_doc_coverage: false — absent means nobody looked, and is never the same as zero.

dependency_cycles lists each import cycle by the files that form it.

They were computed and dropped. The per-entity verdict fed one term of the fragility score and nothing else, so a coverage figure raised the obvious question — which entities? — and the report had no answer; a consumer had to re-parse every file and re-implement the rule. The cycle detector returns the members, and the report said "2 dependency cycle(s) detected", which is a number nobody can open.

entities[].description and entities[].dependencies are gone. No extractor in any language ever filled either, measured across five codebases — an always-empty field is a promise, not a feature — and call_edges already says what an entity depends on.

Every number in the report can be recomputed from the report

A number nobody can check is a number nobody should trust. These are the recipes, and they were run against seventeen real codebases: every one reproduces exactly.

metrics_by_entity[q].fan_out and .fan_in count DISTINCT edges of the entity call graph — the edges of call_edges whose caller and callee are both entities of this report, with duplicates on different lines counted once.

pairs = {(c["caller"], c["callee"]) for c in r["call_edges"]
         if c["caller"] in names and c["callee"] in names}
fan_out = sum(1 for a, _ in pairs if a == q)

Counting rows of call_edges instead gives a larger number: a call to a library is an edge and not a graph node, and the same call written on three lines is three rows and one edge.

reachable_from_entry is what a walk of those same edges reaches from every entity carrying an entry_point.

hotspots[].score is fan_out × 0.35 + cyclomatic_complexity × 0.40 + depth × 0.25, and an entity is listed when it reaches 3.00. Compute it in hundredths: fan_out × 35 + cyclomatic × 40 + depth × 25 >= 300. The weights sum to one and the metrics are counts, so scores land exactly on the threshold — fan_out 6, cyclomatic 1, depth 2 is exactly 3.00 — and in binary floating point that comparison answers differently at 32 and at 64 bits. Integers answer the same everywhere.

metrics_by_entity[q].depth is the longest chain of call-graph components reaching the entity: collapse each cycle to one node, then a component's depth is one more than the deepest component that calls into it, and zero when nothing does.

fragility.score is dependency × 0.6 + (1 − doc_coverage) × 0.4, where dependency is min(avg_fan_out ÷ 20, 1) × 0.7 + min(cycles ÷ files, 1) × 0.3. files is the size of file_hashes, cycles is the length of dependency_cycles, and avg_fan_out is the number of DISTINCT file-to-file edges divided by files — an edge being a dependencies entry that is not extends or implements whose source and resolved_target are two different files of this report.

Two of those three inputs used to be absent, so the headline number of the report could not be checked at all.

risk_index.score is complexity × 0.35 + coupling × 0.25 + documentation × 0.25 + cycles × 0.15, and every component is published beside it so the sum can be taken apart:

Component How it is derived
complexity min(max ÷ 40, 1) × 0.5 + min(share ÷ 0.05, 1) × 0.5, where max is the highest cyclomatic_complexity in metrics_by_entity and share is the fraction of its entries above 10
coupling min(avg_fan_out ÷ 4, 1), the same avg_fan_out as above
cycles min(len(dependency_cycles) ÷ 3, 1) — a count, not a ratio to file count: one import cycle in a hundred files is one architectural defect, and dividing it by a hundred says it is nothing
documentation 1 − doc_coverage

Why a second score rather than a rebalanced one. Measured across eleven crates, 88–100 % of fragility is its documentation term: the structural half contributes 0.007 to 0.06 in practice. Its fan-out divisor is 20 for a quantity that ranges 0.00–3.00, and cyclomatic complexity is not an input at all. fragility is left exactly as it is because it is a published field with consumers — a score that quietly starts meaning something else is worse than one that is wrong in a known way.

The divisors above are set from measured ranges, not chosen: complexity peaks at 7–36 across those crates, the share above 10 runs 0–3.3 %, and file-to-file fan-out 0.00–3.00.

production repeats doc_coverage and risk_index over production code: every entity without non_production.

Field How it is derived
entities[].non_production path when the file sits where tests, specs, fixtures, examples, benchmarks or fuzz targets live (tests/, examples/, benches/, fuzz/, *.test.ts, *_test.go, test_*.py, conftest.py…), test_attribute for Rust #[test] items and items inside #[cfg(test)] modules. Absent means production code
production.entities How many entities carry no non_production
production.doc_coverage The share of those entities whose documented is true, among those that have a documented
production.risk_index The risk_index recipe above, with two changes: complexity reads only the metrics_by_entity entries not named by a non-production entity, and documentation is 1 − production.doc_coverage. coupling and cycles are the whole tree's

Coupling and cycles stay whole-tree because they are measured between files, and an import cycle through a test file is still a cycle in the tree. The whole-tree fields keep their meaning: on muundo itself 62 % of the entities are tests, which is why both answers are published.

cyclomatic_complexity is read from the source, not from the graph, so it is checked by re-running the analysis rather than by arithmetic — which is what muundo verify does.

A qualified name says where an entity lives

entities[].qualified_name is <file>::<scope chain>::<name>, and the scope chain is what keeps two entities apart: a method carries the type it is implemented on, a nested function its parent, a C++ member its namespace.

Rust methods used to carry nothing. A Rust impl block names its subject in a field the extractor was not reading, so every method of every type was reported as a top-level function with a bare name. On muundo's own tree that was 2 363 of 2 619 entities mislabelled, and 29 qualified names each covering between two and fourteen different functions. metrics_by_entity, hotspots and reachable_from_entry are all keyed by qualified name, so the functions sharing a key shared one set of numbers and the rest had none.

Two same-name entities can still share a key, and both are honest:

  • a function defined twice under #[cfg(unix)] and #[cfg(windows)] — one function, compiled one way or the other;
  • an overload, which needs types to tell apart and is the consuming application's job (see Design decisions). In Rust that is two impl From<A> for X / impl From<B> for X blocks; in C++ and Ada it is the ordinary way to write a library, and it is where nearly all of these are — 305 entities across a measured corpus, against 21 #[cfg] pairs;
  • a callable bound to a property of an ANONYMOUS object that repeats inside one function. tenants.list and workspaces.list are two names, and the index tells apart the elements of one array, but six separate nextSteps: [{ action: … }] literals passed to six different calls give their first elements the same path. Eleven entities in one real file of 1 941, each a one-line callback.

What a declaration records about its own body

Three fields, and they cover different amounts of it:

Field What it holds
source_snippet The declaration's text, cut at 500 characters with ...
body_features Signals extracted from the body: use-def chains, argument flows, loop spans, a few counts
content_digest BLAKE3 of the declaration's exact bytes, in hex

content_digest exists because the first two are not enough. Measured on pallets/click, dtolnay/anyhow and sindresorhus/ky: an edit deep inside a long function — past the snippet cap, moving no body feature — left no trace at all. A diff of the two reports named the FILE and no declaration, and called itself complete, because the reports genuinely did not differ. 42 % of muundo's own core/ declarations are long enough to fall in that hole.

The digest closes it: any edit inside a declaration changes it. And a declaration that only MOVED keeps it, because bytes are not positions — which is what lets a diff go on telling a move from a change.

A declaration's bytes include what is written above it. source_snippet and content_digest cover the declaration as the grammar sees it — a Python @decorator, a Rust attribute, an annotation — while line_number names the def/fn line itself. So a @pytest.mark.parametrize list that gains a case changes the digest of the test below it, and the recorded span does not move. That is deliberate (the decorator is part of the declaration) and it is stated here because the two ranges are not the same range.

A callee is a name

call_edges[].callee and body_features.argument_flows[].callee name what is called. Where resolution succeeded that is a qualified name (src/db.ts::connect, std::path::PathBuf::from); where it did not, it is the name as written (collect, express.Router).

It is never a piece of source. A method chain is an expression, and the part of it that names the call is the segment after its last top-level separator: v.iter().map(…).collect is a call to collect. A type argument is not part of the name — collect::<Vec<_>> and collect::<HashSet<_>> are one function.

A closure called on the spot ((|| { … })()) has no name of its own and is called <closure>. It used to be handed back as its own source — a few hundred characters of code in a field a consumer reads as an identifier. <closure> invents nothing: it says the one true thing about the callee. Across seventeen real projects, ONE callee in 118 000 is still not a name, and it is a Python expression naming two candidates ((analyzer or _fallback)).

Everything else that is not a name is removed rather than kept. A type argument (decode_as<Json>), a leading operator word (await invoke), a pointer arrow (it->is_null) and a literal receiver (2_u64.pow, /re/i.test) all named a call after something that is not the call.

A dotted name is a module, not a file. verify reads a name shaped <head>::<rest> as a claim about the file <head> when <head> is a path — it holds a /, or it ends in an extension muundo reads. importlib.util::find_spec and xml.etree::parse name modules, so nothing is asked to hash them.

The rule used to accept any extension, and a Python module path is dotted: importlib.util was read as a file with the extension util, and verify refused the report for naming a file its manifest did not carry. Measured: 665 such claims in one codebase, 407 in another, and in a third a single call of this shape refused the whole report.

What became of a call

call_edges[].resolution says what became of callee, the way an import edge says what became of its target:

resolution Meaning
in_tree callee is the qualified name of an entity in this report
outside It arrives through an import that names something this analysis does not follow: a package, a standard library, another module path
unresolved It arrives through an import that names THIS tree, and nothing here answers to it — a gap in this report
undetermined No evidence either way
parameter callee is a parameter of the function around the call, declared at declaration_line; the code that runs is whatever each caller passes
local callee is a local of the scope around the call, declared at declaration_line, holding a value this file does not write as a function: the result of a call that returns a declared type, an element of a loop, a let typed with a function type

undetermined is the honest majority, and it is the point. A call to unwrap, len or map is a method on a receiver whose type this analysis does not track. Nothing about it says the target is external, so nothing here says so. Measured across six trees in six languages: in_tree 8–19 %, outside 0–25 %, unresolved 0–21 %, and undetermined the rest. Without this field all of it was one raw string, so a caller could not tell "muundo declines to follow this" from "muundo missed this" — and "who calls this function" quietly inherited the difference.

call_edges[].declaration_line names the overload. A qualified name is not an overload id: JsonConvert.cs::JsonConvert::SerializeObject is eight declarations, and an in_tree edge naming it names all eight. Where what the call passes — how many arguments, and the types the source writes for them, against the types written for each declaration's parameters — fits exactly one of them, that declaration's line_number is here, and callee with it is one declaration. Absent where the name is one declaration, and where more than one fits: a reader then has the set, never a guess. Nothing is inferred to fill it. A literal has a type, a construction names one, a plain name has the type its own declaration wrote, a call has the type the declaration it reached returns, this is the caller's class, X.class a Class, a lambda a function with no name. Two class types are judged by the hierarchy this tree WRITES: a declared type may be passed as its written bases and as a type outside the tree only where its ancestry leaves the tree, nothing outside the tree extends a type inside it, and two types the tree does not declare never refuse each other. Where several overloads fit and every argument's type is known, the most specific of them is named, as Java and C# name it, and a fixed-arity overload before a variadic one, as their phases order them; a spread argument (*args, ...args) reaches only a variadic. In Java, C# and Kotlin the set spans the written hierarchy — the base's WriteValue(char?) over the override's WriteValue(object?) for a char? — and the edge's callee moves to the base's declaration when the base's wins; a private or protected member the caller cannot reach is out, as is a C# explicit interface implementation reached through anything but its interface; a call that writes type arguments reaches a declaration with as many type parameters. A Rust method a type derives — clone, default, eq, cmp, hash, fmt — reaches the type. Kotlin's trailing lambda is an argument, its vararg and default values are the parameter's, and a call on a receiver reaches an extension function through the receiver's type, then the caller's Gradle source set, with the expect half of an expect/actual pair set aside. A bare Kotlin call inside a lambda with a receiver — buildCodeBlock { add(x) }, with(b) { … }, x.apply { … } — asks that receiver before the class around it, as Kotlin does: a local first, then each receiver from the innermost outward. A member a Kotlin class delegates to a standard interface with by (MutableMap<…> by map) reaches the class, at its own line, where kotlinc writes the forwarding method. In Go, s[i].M() on a named collection type (type byName []*Command) reaches the element's M.

--lsp <command> asks a compiler instead. A language server — the flag takes its command, rust-analyzer or clangd — knows what a name holds, which overload an argument list picks and what a receiver's type is. On anyhow, against rustc's own debug info, the call sites placed correctly go from 59 to 119 of 120. Three things follow, and none is optional: the server's name and version join analyzer_provenance.resolvers and therefore the snapshot id, so two toolchains are two analyses; every edge it placed carries resolved_by; and verify on a machine without that server answers resolver_absent rather than replaying a weaker analysis and blaming the difference on the tree.

--bind-unique-names asks for a different kind of answer. muundo does not track what a name holds, so x.m() has no evidence of where it lands and stays undetermined. Where the tree declares that name EXACTLY ONCE there is nothing to choose between, and this flag binds it. It is OFF by default and it is recorded in the report's configuration, because it changes what in_tree MEANS: every other in-tree edge is proved by something the source writes, and one bound this way is proved by the absence of an alternative — which a file added tomorrow can remove without touching either end.

What it costs, measured against a compiler on caller→callee pairs:

found, off found, on contradicted, off contradicted, on
gson, against javac 3 600 3 936 80 401
clap_builder, against rustc 610 1 130 28 336

On Rust it nearly doubles what is found and the compiler contradicts about one of every two it adds; on Java it adds one right answer for every wrong one. Whether that is a good trade depends entirely on what the report is for, which is why it is a flag and not a rule.

Two settings go with --lsp. --lsp-budget <N> caps how many calls are asked about (default 2000), and --lsp-start-timeout <SECONDS> how long to wait for the server to open the project (default 180). It is one deadline for the whole start: the answer to initialize and the wait for the server to go quiet once it has the project. A server still busy when it runs out is recorded as unavailable, and the analysis goes on without it. The setting matters on a real project: a server reads a BUILD, and OmniSharp restores NuGet packages before it answers anything. Where a server cannot be started, the provenance says so — unavailable: <the server's own words> — and not one edge changes.

The command is the whole configuration, arguments included, because that is how a server is told where the project is: --lsp "ada_language_server --config als.json", --lsp "OmniSharp -lsp -s Src/Thing.sln", --lsp "jdtls -data /tmp/ws".

How far to trust each value is not a matter of taste: an edge called in_tree is right 1.000 of the time against clang on C, 0.944 against rustc on Rust and 0.820 against clang on C++, and of the calls it does not place it says so rather than misplacing them 0.86 to 0.98 of the time. See What has been measured.

A callee only becomes outside on the evidence of an import the file writes. That is the same rule, and the same code, the dependency edges use.

in_tree is exactly the edge set the numbers rest on. Fan-in, fan-out, depth and every hotspot score are computed over the call graph filtered to edges whose callee names an entity of this report — the same membership as this field, decided in one place so the two can never disagree.

What became of an import

An import edge carries what the source wrote (target) and what this analysis made of it:

resolution Meaning resolved_target
in_tree It names a file in this analysis that file
unresolved It names THIS tree — a relative path, a crate:: route, an alias the project's own tsconfig.json claims — and nothing here answers to it absent
outside It names something this analysis does not follow: a package, a standard library, another module path absent

The three used to be one shape. express, @app/db and ./near all came back as the string the source wrote, so a package muundo declines to follow, an alias nothing answered to, and an edge the engine had already resolved to src/near.ts were indistinguishable — which is why an unresolved import read as a bug. outside is a refusal by design (see Design decisions); unresolved is a gap in the report, and the difference is now in the report rather than in a document.

resolved_target is a file this report claims about, so verify covers it: a report naming a file its manifest does not carry is refused as unverifiable.

Edges that are not imports (extends, implements, the FFI and state-flow edges) already name an entity in this report and carry no resolution.

The configuration that decided how imports resolve

config_inputs lists every tsconfig.json, jsconfig.json and inherited (extends) file the analysis read and used, each with the BLAKE3 digest of the bytes it read and a kind of ts_config, js_config or extended. Paths are relative to the analysed root, exactly like a file_hashes key.

It is there because a config decides what the code means. tsconfig.json says that @app/db is src/lib/db.ts. Repoint that alias and every call edge and dependency that crossed it is different — while every source file is byte for byte identical. verify re-read source only, so it answered "unchanged" for a report describing a graph that no longer existed. It now re-reads the configuration too, and a changed alias fails verification naming the config.

verify reproduces the report; it does not only re-hash it. After checking the hashes, it rebuilds the analysis options from the report's recorded configuration (clamped to the MUUNDO_MAX_VERIFY_* ceilings, so an untrusted report cannot demand more work than the operator allows), re-runs the analysis from the root, and compares every source-derived field — entities, dependencies, call edges, metrics, scores, hotspots, snippets, the file set — against the report, excluding only the fields NOT derived from the source content: the extraction timestamps, the analysed root's own name, and the tool provenance. This is what a hash cannot see: a metric, score or snippet edited while its file's bytes were left alone, or a source file added since the report (never in the manifest, so never hashed). Any of them makes the fresh analysis diverge, and the verdict carries the divergent fields and is verified: false. (When re-reading already found a mismatch, a missing file or a read error, the verdict is already false, so the re-analysis is skipped — it could only confirm it. The verdict then says not_attempted, because a check that did not run is not a check that passed; see What a verify verdict says.) A report that records no configuration cannot be reproduced and is refused as invalid_report — including an empty one: "I found nothing" cannot be checked against the current root without the options that produced it, so a provenance-free empty report never verifies against a tree full of code. The rebuilt options are strict: an unknown analysis plan (not full or fragility) or an unknown TypeScript state-flow strategy is refused as invalid_report rather than silently normalised into a different analysis, and the recorded cross_filesystem setting is carried over so a report that descended through a nested mount is reproduced the same way. Reproduction needs the same engine build; verify with the version that produced the report.

Hashing what was read is only half of it. The config that decides is the NEAREST one above the importing file, so a tsconfig.json that did not exist when the report was made and does now re-points every alias under its directory — while every file and every config the report listed is byte for byte what it was. config_selections carries the other half: one record per directory of TypeScript/JavaScript source, with

Field What it says
context The directory, relative to the analysed root ("" is the root)
candidates The paths examined that held NO directory entry, in the order the walk asks: <dir>/tsconfig.json, <dir>/jsconfig.json, then the same two in each parent
selected The configuration that governs it, absent when none does
decided_outside_root The decision was made above the analysed root — this report cannot name it and verify cannot check it

verify checks that the empty places are still empty and that the chosen file still hashes to what the report says. Only then is "the same configs would be selected again" a fact rather than an assumption.

A decision made outside the analysed root cannot be verified from it. That happens when the containment boundary is wider than the root — which is what the HTTP server does: the walk finds the governing tsconfig.json above the root, and the report cannot name it. Such a report (decided_outside_root: true) never receives a successful verify verdict: nothing under --root says whether that configuration still resolves imports the way it did, so verify refuses it as unverifiable (invalid_report) rather than answering a narrower question than the one asked. Analyse and verify from a root that contains the configuration.

And it does not take the records on trust. A document that simply dropped them would otherwise verify again on exactly the tree they exist to catch. So verify derives which directories must have a record — from the source the report itself lists — and re-derives each candidate chain from the walk rule. A record that was deleted, or emptied, is refused as unverifiable (verify exits 2, reason: invalid_report), not answered with a quieter success.

A place that held an unusable config is not an absence: the entry exists, so recording it as empty would fail a tree nobody touched the day the link's target appears. Those are named in partial_analysis instead, and a report carrying one is already not complete.

A config above the analysed root is used and not listed. Resolution walks upward from a source file, and where the containment boundary is wider than the root — which is what the HTTP server does, authorising a request against a workspace base — it can find a config outside the tree being analysed. That file has no name relative to the root, so listing it would mean publishing an absolute path on the analysing machine: unverifiable anywhere else, and a disclosure besides. Instead the report raises a config_outside_root note in partial_analysis, which makes is_complete false. Analyse from a root that contains the config to close it.

A config that was refused — oversized, over the shared byte budget, or unparseable — is not an input either: it decided nothing. Those are named in partial_analysis under config_read and config_parse.

Why a file was skipped

Every entry in skipped_files carries a reason for a person and a reason_code for a program. The sentence may be reworded at any time; the code is stable and is what to branch on. It is also path-free by design: a skip reason names no file and no boundary, because a report — and an HTTP error built from one — travels further than the machine that produced it.

reason_code What happened
containment_escape The path resolved outside the containment boundary
containment_swap The file opened was not the in-tree file it was admitted as
containment_open_refused The open was refused under containment (a final-component symlink, or the open failed)
unresolvable_path The path could not be resolved — a symlink with no target, or an unreadable component
not_a_regular_file A directory, FIFO, device or socket wearing a source file's name
unnamable_path The path has no spelling a report can carry (not UTF-8, or containing ::)
outside_analysis_root Inside the boundary, outside the analysed tree — a link out of the requested directory
file_too_large Over max_file_size
unreadable Metadata or content could not be read
duplicate_alias A second name for a file already analysed under its real path
discovery_error The directory walk failed here, so part of the tree was never enumerated
mount_boundary Discovery stopped: the directory is on a different filesystem
parse_error The file was read but could not be decoded or parsed

The first three are the ones a server treats as a security event: the HTTP surface answers 422 and puts the same code in the error envelope's reason. Everything else is ordinary partiality and comes back with the report.

Completeness

A report is complete only when skipped_files and partial_analysis are both empty.

They are not the same thing and neither implies the other. A file muundo could not decode is in skipped_files and contributes nothing anywhere — not an entity, not a call edge, not a hash — while partial_analysis stays empty and the report looks whole. A tree where every file parsed but the state-flow stage hit its cap is the other way round.

Either one non-empty means every count in the report is a count over a subset, and nothing else in the report says which subset. Read is_complete before you quote a number:

if not report.is_complete:
    print("not read:", report.skipped_files)
    print("stopped early:", report.partial_analysis)

In Rust it is report.is_complete(); in JSON, both arrays are present and a consumer checks them itself.

Python

import muundo

analyzer = muundo.MuundoAnalyzer(root, languages)
report = analyzer.analyze()

The parse runs once per analyzer and is cached for its lifetime; the options are fixed at construction, so a fresh analysis means a new analyzer.

CLI — muundo

The command is muundo. It is built by the muundo-cli crate, which is a packaging name and not something you type.

Every subcommand writes one JSON object — {"ok": true, "data": …}, or an error.

Subcommand What it does
analyze Full analysis of a tree
metrics Metrics for one named entity
fragility Fragility score for a tree. The FAST plan: it skips cyclomatic metrics, so risk_index is absent — use analyze for that
hotspots The riskiest entities, most first
verify Re-run the analysis and check that a report reproduces — its source hashes, the configuration that resolved imports, AND every source-derived field
diff Compare two reports: what changed, and what to look at again
snapshot Keep an analysis between two questions: save, list, show, ref, prune
query Ask a stored snapshot one question, without reading the report: entity, callers, callees, file, summary, freshness
info Version, accepted language names, and host information

Global options:

Option Meaning
-v, -vv, -vvv Verbosity: info, debug, trace
--input FILE Read the JSON request from a file instead of the flags

Per subcommand:

Subcommand Options
analyze --root (default .), --languages (comma-separated), --output json\|pretty, --ts-stateflow-strategy, --fail-on-incomplete (exit 4 when code was left out), --fields (answer with a view), --max-items
metrics --root (default .), --entity
fragility --root (default .)
hotspots --root (default .), --top-n (default 10)
diff --before, --after (a file, -, or @<id-or-name>), --depth (default 1), --max-items, --store
snapshot save --root, --report, --languages, --label, --revision, --ref, --store
snapshot list / show / ref / prune --store; prune takes --keep (default 5) and --max-age-days
query … --snapshot (default head), --store; callers/callees take --depth (default 1) and --max-items; file takes --max-items; summary takes --top; freshness takes --root
verify --report, --root (default .), --workspace-base (a report whose containment_root_supplied is true needs it — see Verifying a report analysed under a wider boundary)

--languages left out means every grammar is tried. Naming the languages you care about is faster and keeps skipped_files to files that really failed, rather than files of a language you never wanted.

The snapshot store: an analysis that survives

Without it there is nothing to compare against. The tree you analysed yesterday is gone; re-analysing gives you today's. muundo snapshot keeps reports so a base still exists after the code moved on.

muundo snapshot save --root . --ref base --revision "$(git rev-parse HEAD)"
# … the code changes …
muundo snapshot save --root . --ref head
muundo diff --before @base --after @head
Command What it does
snapshot save Analyse a tree (or take --report FILE) and keep it. Prints the id, and reused: true when the store already held exactly this analysis
snapshot list What the store holds, newest analysis first, with the names pointing at each. Analyses that share a second are ordered by when each was stored, so prune keeps the ones a reader would call newest
snapshot show <id\|name> The record: tree, time, engine, label, revision, size
snapshot ref <name> <id\|name> Point a name at a snapshot
snapshot prune --keep N [--max-age-days D] Keep the newest N of each tree, and everything a name pins

Where it lives, and why not in the tree. MUUNDO_SNAPSHOT_DIR, else $XDG_CACHE_HOME/muundo/snapshots, else ~/.cache/muundo/snapshots — never under the analysed root. A store inside the tree would be read by the next analysis and change the very snapshot it holds.

A snapshot is found by its id, by an unambiguous prefix of eight characters or more, or by a name. A name is how a person says "this one is the base", and it is the whole answer to which snapshot that is: a base is a decision, not something a tool can infer from a commit graph it cannot see. --revision is recorded beside it as context, never as identity.

A pinned snapshot is never collected. prune reports what it kept and why, because a comparison that works today must not stop working because somebody analysed the tree ten more times.

Saving the same analysis twice writes it once. The id covers the sources, the configuration and the engine, so the same id means the same report — and a different engine or different options simply give a different snapshot, which can never be mistaken for the first.

A snapshot of muundo's own core/ is 11 MB and takes 1.0 s to save; the store is uncompressed, so --keep is what bounds it.

Asking a snapshot one question

muundo query answers from an index written beside each snapshot — the declarations, the call edges and the totals, without the bodies, the snippets or the state-flow points. Measured on a 259 000-line tree: 85 ms, where analysing it to answer the same question takes 3.90 s.

muundo query callers stamp_call_resolution        # who calls it
muundo query callees analyze --depth 2            # what it reaches, two hops
muundo query entity src/db.ts::connect            # what it is, and where
muundo query file core/src/diff.rs                # what a file declares
muundo query summary --top 10                     # the tree from above
muundo query freshness --root .                   # is the snapshot still true

Every query takes --snapshot (an id, an eight-character prefix, or a name; head by default) and --store.

Every answer says three things about itself.

Field What it says
of The snapshot that answered, so the claim leads back to a verifiable report
limits.complete True only when nothing was cut, nothing was unplaced, and the analysis behind it was whole
limits.unresolved_candidates Calls the analysis could not place whose callee is spelled like something in this answer — each one might belong in it
limits.snapshot_incomplete The analysis skipped files or stopped a stage early, so every count is a count over a subset
limits.truncated What --max-items cut, with what it kept and what there was

A traversal walks placed edges only. An edge whose callee was never resolved names a function whose home is unknown, and following it would be inventing one — so callers is a lower bound whenever unresolved_candidates is not zero, and says so rather than looking complete.

A short name is accepted while exactly one declaration answers to it. Two declarations called run are two different questions, so an ambiguous name is refused (entity_not_found) rather than answered with one of them.

freshness is the cheap half of a verification. It re-reads the files the snapshot listed — 0.003 s for muundo's own core/ — and exits non-zero when any has changed or gone. Every answer carries the caveat: a file added since is not in the manifest, so nothing here looks at it, and a metric edited while its file's bytes were left alone reproduces perfectly. verify answers both; this answers "is it still worth reading".

An index is built when a snapshot is saved, from the report already in hand. A snapshot stored before indexes existed is indexed the first time it is asked, and an index written by an older recipe is rebuilt rather than read: a field that moved is a wrong answer, not a missing one.

A diff: what changed, and how far the answer holds

muundo diff --before A.json --after B.json compares two reports. On a one-function edit in muundo's own core/ it answers in 5 KB against an 11 MB report.

Field What it says
before, after The two snapshot_ids compared. A claim here is worth what those two snapshots are worth
analyzer Present only when the ENGINE differs between the two snapshots — read it before anything below it
files Paths added, removed, or hashing differently
entities Declarations added, removed, or changed — each change naming its file, its line and which fields differ; moved counts the ones that only shifted position
calls Call edges added or removed, as caller -> callee; moved counts the ones that only changed line
dependencies Import and other edges added or removed
metrics Per-entity metrics that moved, one row per metric
scores Whole-tree scores that moved: fragility, doc coverage, risk index, production
module_use_def_changed Modules whose use/def chains differ, by name
completeness Files newly skipped, stages newly partial, and later_snapshot_read_less
revalidate What to look at again — see below
unexplained_fields Differences nothing above accounts for
truncated Every list --max-items cut, with what it kept and what there was

A diff between two engine builds is not a diff of the code. A parser upgrade reads the same bytes differently: entities appear, edges resolve where they did not, metrics move, and nobody touched the source. The snapshot id already covers the engine, so two such analyses can never be confused — and when a diff spans them it says so in analyzer, before the lists.

A NAME DOES NOT ALWAYS IDENTIFY A DECLARATION, and the diff no longer pretends it does. Three nested cmd functions in one test share a qualified name; a class can carry two methods the grammar spells the same; an anonymous handler is named <route_handler#10> by WHERE IT SITS. So same-named declarations are paired rather than looked up: identical bodies first, by their content digest, then what is left in source order. Three consequences a reader should expect:

  • a name may appear more than once in added, removed or changed — two declarations sharing a name are two declarations;
  • each changed entry carries line_number, which is what tells the siblings apart when the name cannot;
  • a synthetic name whose NUMBER moved because something was inserted above it counts as moved, not changed. Its number is a position.

A declaration that only moved is not a declaration that changed. Inserting one function shifts every declaration below it, and every line number recorded inside their bodies with them. Those are counted in entities.moved, not listed: on a three-file change to muundo's own core/, 32 declarations differed and 31 of them had only moved. What survives the filter is substance — a signature, a body, a visibility, a parameter that is now read. A list of lines (use_lines) is compared by its length, so a variable read twice where it was read once still shows up.

revalidate is the part a generic differ cannot produce. It walks back up the call graph from what changed — --depth hops, one by default — and then says how far that answer can be trusted:

"revalidate": { "depth": 1, "entities": ["m.py::run"],
                "unresolved_candidates": 4, "complete": false }

The walk uses edges resolved in_tree, because an edge that was never placed cannot be walked. unresolved_candidates counts the calls this analysis could NOT place whose callee is spelled like something in the set — each one might be a caller nobody listed. When it is not zero, complete is false and the list is a lower bound on what to re-check, which is the honest thing to hand a reviewer.

unexplained_fields is why a diff can be read instead of the report. The two documents are also compared whole, exactly as verify compares them, and every field the structured answer actually spoke about is struck off. What is left is named. An empty list means the structure above IS the whole difference; a non-empty one means this build does not model that field and says so rather than reporting "nothing changed" about two documents that are not the same.

diff does not verify either document — verify does that, against a tree. It compares what it is given, which is why both snapshot ids travel in the answer.

What a verify verdict says

verify writes one object. verified is the whole answer; the rest is what it rests on.

Field What it says
verified true only when every re-read claim held AND a fresh analysis ran and matched
checked How many inputs the report named: source files, configurations, and the places a configuration had to stay absent
checked_source How many of those are source files
checked_configuration How many are tsconfig.json / jsconfig.json files that were read
checked_configuration_absent How many are places re-checked as still holding no configuration
mismatches Files whose bytes no longer hash to what the report recorded, each with expected and actual
missing Files the report names that are no longer there
errors Files that could not be read, with the cause
reproduction What the fresh analysis did: state and reason (below)
divergent_fields Which source-derived fields the fresh analysis produced differently

reproduction exists because an empty list is not an answer. The replay is skipped when re-reading already failed, so divergent_fields: [] used to mean either "nothing differs" or "nothing was compared". The verdict now states which:

state reason What happened
matched reproduced_exactly A fresh analysis ran and every source-derived field matched
diverged fields_differ A fresh analysis ran and divergent_fields names what differs
diverged source_workload_exceeds_report The tree now holds more files or bytes than the analysis that produced the report was allowed to read, so the replay could not finish: the tree grew away from its report. divergent_fields names the grown dimension, source_file_count or source_total_bytes
not_attempted claims_already_failed No fresh analysis ran: a hash mismatch, a missing file or a read error had already answered no, and divergent_fields is empty because nothing was compared
not_attempted resolver_absent No fresh analysis ran: the report was produced with a LANGUAGE SERVER, named in analyzer_provenance.resolvers, and a replay asks nobody. Replaying without it would compare the report against a WEAKER analysis and report the difference as the tree having changed

divergent_fields is the detail behind a divergence. Read state to know whether anything was compared at all.

A replay stopped by a ceiling the VERIFIER imposes is a different thing: the command refuses with verification_workload_too_large and writes no verdict, because nothing was proven either way. Raise MUUNDO_MAX_VERIFY_TOTAL_BYTES or MUUNDO_MAX_VERIFY_FILE_COUNT and run it again.

A view: a bounded answer that says what it left out

analyze --fields <families> answers with a view instead of a report. A full analysis of muundo's own core/ is 11 MB; --fields entities is 947 KB, and --fields entities --max-items 3 is a few lines.

Family What it carries
entities entities, without their bodies
entity_bodies the source_snippet and body_features inside them — 3.6 MB of the 5.9 MB core/'s entities weigh
dependencies dependencies, dependency_cycles
calls call_edges
metrics metrics_by_entity
scores fragility, hotspots, bridge_nodes, risk_index, doc_coverage, production
reachability reachable_from_entry, module_use_def_chains
state_flow state_flow_points, advisory_points
contracts boundary_contracts
manifest file_hashes, config_inputs, config_selections — what a verifier needs
metadata metadata

A view is a different document from a report, and it declares itself:

"view": {
  "of": "6a52e341…",
  "selected": ["entities"],
  "omitted": ["entity_bodies", "dependencies", "calls", "…"],
  "truncated": [{"field": "entities", "kept": 3, "total": 3004}]
}
  • of is the snapshot_id of the report it was cut from, so any claim in it leads back to a full, verifiable document.
  • A family that was not selected is absent from the JSON, never empty. An empty collection in a view means what it means in a report: there were none. That is the whole reason for the declaration — call_edges: [] cannot be allowed to say "not asked for", "none found" and "cut short" at once.
  • --max-items N caps every collection, and each cut is named with what it kept and what there was.

What a view may never drop: skipped_files, partial_analysis, analyzer_provenance and snapshot_id ride in every view whatever was selected. A projection may cost the reader facts; it may not cost them the knowledge that facts are missing.

A misspelled family is refused (invalid_request) before the walk, naming the families that exist — ignored, it would hand back a view with nothing in it and no complaint. --max-items without --fields is refused for the same reason.

A view is not verifiable on its own: verify reads reports. Cut a view for reading, keep the report for proving.

Exit codes

A script has to tell "the answer is no" from "I could not answer". A gate that reads them as one thing either fails the build on a broken check, or — worse — passes it.

Code Meaning Example
0 It worked verify matched
1 The command ran and the answer is no the tree changed since the report; the named entity is not in it
2 You asked wrongly — fix the invocation, not the code a root that does not resolve, a language that is not one, MUUNDO_MAX_FILE_SIZE=abc, a report that is not a report
3 muundo could not answer — nothing is proven either way the engine failed, the answer could not be written out
4 The analysis answered but left code out — only with analyze --fail-on-incomplete a file was skipped (too large, unreadable, outside the tree) or failed to parse

2 is also what the argument parser answers for a misused command line, so "you asked wrongly" is one number whether clap or muundo noticed.

4 is opt-in. By default an incomplete analysis exits 0: the report is on stdout and its skipped_files / partial_analysis say what was left out, but a gate that keys only on the exit code would not see it. Pass analyze --fail-on-incomplete and that same run exits 4 instead — the report is still written first, so a CI step can capture it and still fail the build. An analysis that read the whole tree exits 0 with or without the flag.

What a refusal looks like

A failure writes one JSON object to stderr and exits non-zero:

{"ok": false,
 "error": "Cannot resolve root path '/nope': No such file or directory (os error 2)",
 "reason": "invalid_root"}

error is a sentence for a person and may be reworded at any time. reason is a stable code — branch on that. Every failure carries one.

reason Exit What happened
invalid_request 2 A flag or a JSON field asked for something that is not a thing
missing_subcommand 2 A subcommand is required and none was given
invalid_root 2 --root does not resolve
invalid_input 2 A named input cannot be read as a file (a FIFO, a device, a symlink)
request_too_large 2 The JSON request is over MUUNDO_MAX_INPUT_BYTES
invalid_report 2 The report is malformed, of an unknown schema, or makes claims its own manifest does not cover
report_too_large 2 The report document is over MUUNDO_MAX_REPORT_BYTES
invalid_configuration 2 A MUUNDO_* value is not a value
verification_workload_too_large 2 Verifying would cost more than MUUNDO_MAX_VERIFY_FILE_COUNT / MUUNDO_MAX_VERIFY_TOTAL_BYTES allow
entity_not_found 1 metrics --entity names something the report does not have
verification_failed 1 The tree does not match the report
output_failed 3 The answer existed and could not be written out

Plus the engine codes below, which the CLI and the HTTP surface both answer.

Engine codes

One vocabulary for what the ENGINE refuses, so the same failure is called the same thing on the command line, over HTTP, and inside a report.

reason Exit (CLI) What happened
root_outside_boundary 2 The root is outside the containment boundary
unknown_language_filter 2 A language token matches no known language
workload_too_large 2 The tree crosses MUUNDO_MAX_FILE_COUNT or MUUNDO_MAX_TOTAL_BYTES
containment_escape, containment_swap, containment_open_refused 3 A path inside the tree broke out of the sandbox — see Why a file was skipped
parse_failed 3 A file could not be read or decoded
io_failed 3 A filesystem operation failed
unsupported_language 3 No grammar for that language
graph_failed 3 The graph could not be built
version_mismatch 3 The pinned analyzer/grammar versions disagree with the lock file
cancelled 3 The analysis was stopped before it finished

HTTP — muundo-server

Six routes:

Route Method What it does
/health GET {"status": "ok", "version": …} — liveness, no analysis
/info GET Version and accepted language names
/analyze POST An analysis
/verify POST Check a report against the tree it describes
/snapshot POST Analyse a tree and keep it
/query POST Ask a stored snapshot one question

The /analyze body:

{
  "root": "/path/to/tree",
  "languages": ["rust", "python"],
  "include_call_graph": true,
  "include_doc_coverage": true
}

languages defaults to empty (every grammar). Both include_* default to true.

root may be absolute, or relative to MUUNDO_WORKSPACE_BASE — the directory this server was told to serve. Either way it is resolved first and checked against that base after, so a relative path that climbs out of it is refused like any other outside path.

The other three bodies:

POST /verify    { "root": "/path/to/tree", "report": { "…": "a report" } }
POST /snapshot  { "root": "/path/to/tree", "ref": "base", "revision": "abc123" }
POST /query     { "kind": "callers", "name": "connect", "snapshot": "head" }

/verify takes the report whole, because the machine that produced it may be nowhere near this one. Its body cap is 64 MiB — here the report IS the body — and the document is parsed under the same structural budget a report gets anywhere else. It answers the verdict described under What a verify verdict says, with one difference worth stating: a failed verification is a 200. verified: false means the check ran and the answer is no. A 400 or a 413 means nothing was checked. The containment boundary is the server's own workspace base, never a value from the request: a caller that could name the boundary could name one containing a tree it was not given.

A report costs memory before it is checked. A verification reserves, from MUUNDO_MAX_SERVER_BYTES, the body plus the report decoded from it (about five times the body) plus one analysis for the replay — in one reservation, before the body is read. It is sized on Content-Length, or on the body cap when no length is sent. A body that could never fit is refused with 413 request_too_large without being read; the cap is 64 MiB or what half the memory budget can hold, whichever is smaller. When the report leaves less than one analysis's worth of memory for the replay, the replay reads proportionally less source, and a tree over that is refused with verification_workload_too_large.

/snapshot analyses and keeps, answering {id, reused, ref, complete}. /query takes kind — entity, callers, callees, file, summary or freshness — plus name, path, snapshot, depth, max_items, top, and root for freshness alone. Both use the store this server resolved at start-up from MUUNDO_SNAPSHOT_DIR, never a path from a request: that would be a place a caller could make this process write.

The guards. /verify and /snapshot cost what an analysis costs — the replay a verification runs IS one — so they take the same API key, concurrency limit, request timeout and weighted memory admission as /analyze. A client that disconnects stops the work. /query runs no analysis and takes no analysis permit; it still needs the key. freshness is the one question that touches the filesystem, and its root is resolved and sandboxed exactly as /analyze resolves one.

The codes do not move: invalid_report, invalid_root and verification_workload_too_large read the same here as on the command line, and so do the store's own codes:

reason HTTP What happened
snapshot_not_found 404 No snapshot answers to that id, prefix or name
snapshot_ambiguous 400 The prefix names more than one snapshot
invalid_snapshot_name 400 A name that cannot be a file name, or an id that is not one
snapshot_too_large 413 The snapshot's index, or rebuilding it, needs more memory than one question may hold here
store_unavailable 500 The store could not be read or written
invalid_report 500 The stored document is not a report this build can read, or conflicts with the one being saved

Over HTTP and MCP, the error sentence for these codes is fixed per code and never names a path: the store's directory is this host's layout. The detail is written to the server's log. The command line, which runs on your own machine, still prints it.

An index is read within the question's memory budget. A question reserves twice its index ceiling (the measured cost of parsing an index and answering from it). When the index file is missing or damaged, it is rebuilt from the report only if that fits the same reservation — a rebuild costs about four times the report's size. Otherwise the question is refused with snapshot_too_large. Saving the snapshot again rebuilds its index, and so does muundo query on the machine that holds the store.

What a refusal looks like

Every refused request answers the same envelope:

{"error": "the analyzed tree contains a path refused by the containment boundary: path resolves outside the containment boundary",
 "reason": "containment_escape"}

error is a sentence for a person and may be reworded at any time. reason is a stable code — branch on that. Every refusal carries one: the server has no way to build a refusal without a code, so a client never has to fall back to reading the sentence.

reason Status What happened
invalid_request 400 / 415 / 422 The body did not parse, or named a field this server does not know
request_too_large 413 The body is larger than this server accepts
analysis_disabled 503 /analyze is not served: no MUUNDO_API_KEY is configured
invalid_api_key 401 X-API-Key missing or wrong
invalid_root 400 The requested root could not be resolved
root_outside_boundary 403 The root is outside the workspace this server serves
storage_not_permitted 403 The root is on storage this deployment will not analyse — see MUUNDO_ALLOW_SHARED_WORKSPACE
unknown_language_filter 400 A languages token matches no known language
workload_too_large 413 The tree crosses a configured ceiling (MUUNDO_MAX_FILE_COUNT, MUUNDO_MAX_TOTAL_BYTES)
parse_failed, io_failed, graph_failed, version_mismatch, unsupported_language 500 The engine could not finish — see Engine codes
report_over_encoded_cap 413 The report is over MUUNDO_MAX_ENCODED_REPORT_BYTES
report_exceeds_spool_capacity 413 The report is larger than the whole spool (MUUNDO_MAX_SPOOL_BYTES)
spool_capacity_unavailable 503 The spool is full of other responses right now — retry
capacity_unavailable 503 No analysis capacity right now — retry
analysis_timeout 408 The deadline passed, or the caller left
report_encode_failed 500 The report could not be encoded
internal_error 500 Something in this server broke

Plus the containment codes — containment_escape, containment_swap, containment_open_refused — answered with 422 when a path inside the analysed tree breaks out of the sandbox. Those come from the engine, and are the same values a report's skipped_files[].reason_code carries.

Two codes are shared deliberately. root_outside_boundary is answered by the handler and, when the path is renamed a moment later, by the engine — one refusal, detected twice. capacity_unavailable covers every admission gate: which one turned the request away is the server's business. Conversely, report_over_encoded_cap and report_exceeds_spool_capacity are both 413 and stay apart, because they name different settings to change.

No refusal names a path on the server. Not the analysis root, not the containment boundary, not the file that was refused. A caller supplied the root and knows it; the rest is the deployment's own layout. The path is in the server's log, where the operator can see it.

That holds for an INTERNAL failure too. When the engine fails — a file it could not decode, a filesystem error, a version drift — the reason names which failure it was and the sentence is a fixed one for that code. The engine's own message, which interpolates the file or the canonical root it was working on, goes to the log and nowhere else.

And when the analysis thread PANICS, internal_error comes back with the same fixed sentence. A panic message is written by whichever assertion failed, deep inside, and it routinely names the file it was working on; it is in the log, which is where it is worth having.

Statuses do not move, and a code never changes spelling once published. A client that branches on the status keeps behaving exactly as it did; the code is what it can branch on more precisely.

Limits, and why they exist

/analyze reads a whole tree and builds a graph in memory, so it is the one route that can be made to hurt. Its guards are configurable and default to:

Variable Default What it bounds
MUUNDO_ANALYZE_MAX_CONCURRENT 4 Analyses running at once
MUUNDO_ANALYZE_TIMEOUT_SECS 60 Wall clock for producing a report
MUUNDO_ANALYZE_BODY_IDLE_TIMEOUT_SECS 60 Silence while streaming the response
MUUNDO_ANALYZE_BODY_TIMEOUT_SECS 3600 Total time to stream the response
MUUNDO_MAX_SERVER_BYTES 4 GiB Memory for every analysis running at once, server-wide (the response spool comes out of it when the temp dir is memory-backed)
MUUNDO_RSS_AMPLIFICATION 160 Memory an analysis is assumed to hold per byte of source
MUUNDO_MAX_ENCODED_REPORT_BYTES 4 GiB Size of the encoded report
MUUNDO_MAX_FILE_COUNT, MUUNDO_MAX_FILE_SIZE, MUUNDO_MAX_TOTAL_BYTES — Per-tree ceilings
MUUNDO_ALLOW_SHARED_WORKSPACE unset Whether to serve from storage the server cannot vouch for

One request's cap and the server's memory are two different limits. MUUNDO_MAX_TOTAL_BYTES caps the source one request may read. MUUNDO_MAX_SERVER_BYTES is shared by every analysis running at once, and each request reserves its worst case from it: MUUNDO_MAX_TOTAL_BYTES × MUUNDO_RSS_AMPLIFICATION. So at startup the server lowers the effective MUUNDO_MAX_TOTAL_BYTES to at most MUUNDO_MAX_SERVER_BYTES / MUUNDO_RSS_AMPLIFICATION / MUUNDO_ANALYZE_MAX_CONCURRENT — about 6.4 MiB with the defaults — and logs a warning when it does. Raising the concurrency therefore lowers the largest request accepted, even on an idle server; why is in the design notes.

What a client gets at each limit: a tree over the cap, 413 workload_too_large; a request beyond the analyses already in flight, 503 capacity_unavailable; a request still waiting for memory when MUUNDO_ANALYZE_TIMEOUT_SECS runs out, 408 analysis_timeout — the wait counts against the deadline.

MUUNDO_ALLOW_SHARED_WORKSPACE

Discovery walks by path, so the analysis root has to stay put while it runs — see The root must not change while the analysis runs. On local storage the only thing that could move it is already on the host. On a network or shared mount, anyone who can write to the export can, which is a different proposition. So the server classifies the storage and refuses to serve from what it cannot vouch for.

Accepted values. 1, true, yes, on accept it. 0, false, no, off refuse it, which is also what leaving it unset does. Case does not matter and surrounding spaces are ignored.

Anything else stops the server, with a message naming the variable and the value. It used to be "set means allowed", so MUUNDO_ALLOW_SHARED_WORKSPACE=no meant yes and a typo meant yes.

What is refused by default: NFS, CIFS/SMB, SMB2, AutoFS and 9p — storage another party can change — and anything the server could not classify: an unlisted filesystem, a probe that failed, or a platform where there is no probe. Classification uses statfs(2) and exists on Linux only, so on any other platform the server needs this variable set in order to start at all. Not knowing is not the same as being safe.

What enabling it does and does not do. It accepts that the set of files offered to the analysis is not under the server's control: a root replaced mid-walk is walked as its replacement, and a mount nested inside the tree is analysed through rather than stopped at. It does not weaken per-file containment — every file is still resolved and checked against the boundary before it is read, so nothing outside the boundary reaches a report either way.

With it unset, a nested mount is not entered, and the report says so: the mount appears in skipped_files, a file_discovery note is raised, and is_complete() answers false. Which storage a directory is on is read as the device id on Unix and as the volume serial number on Windows — the same question, asked the way each platform answers it.

MUUNDO_MAX_TOTAL_BYTES is one budget covering everything an analysis reads: source files and the configuration files that resolve their imports (tsconfig.json, jsconfig.json and the whole extends chain). Configuration used to have a second budget of the same size beside it, so an analysis could read twice what it declared — and on the HTTP surface, twice what the memory admission had weighed it for. A configuration that does not fit is skipped and resolution degrades; the analysis does not fail.

What verify may spend

Variable Default What it bounds
MUUNDO_MAX_REPORT_BYTES 512 MiB How long the report DOCUMENT may be
MUUNDO_MAX_REPORT_NODES 64 Mi What that document may EXPAND INTO
MUUNDO_MAX_VERIFY_FILE_COUNT 200 000 How many inputs a report may ask to be re-read
MUUNDO_MAX_VERIFY_TOTAL_BYTES 8 GiB How many bytes verification may read, ACROSS both its hashing and its re-analysis
MUUNDO_MAX_SPOOL_BYTES derived Disk the HTTP server uses to spool a large report

MUUNDO_MAX_REPORT_BYTES bounds the document verify reads; MUUNDO_MAX_REPORT_NODES bounds what that document expands into. They are independent: raising one does not move the other, and a document is refused by whichever it actually crosses — by name. They used to be one number, with the raw input capped at the structural allowance, so the smaller governed both: every report over 64 MiB was refused while the byte ceiling said 512 MiB, and the refusals included reports muundo had just written, because analyze indents its output and whitespace is document without being structure. They are different numbers, and the second is the one an untrusted report controls: a report can be small at the top — every list within its cap — and enormous underneath one of them. Every element, map entry, string and metadata node is charged against one allowance for the whole report as it is built, and crossing it stops the parse.

MUUNDO_MAX_VERIFY_TOTAL_BYTES is one aggregate ceiling for the whole verification, not a fresh budget per phase. Verify does two things that read bytes — hashing the files the report listed, then re-running the analysis to reproduce it — and the re-analysis reads from what the hashing LEFT, so the two together never read more than the ceiling. A report whose two phases each fit but whose combined bytes cross it is refused as verification_workload_too_large (the ceiling is the verifier's, so the report is not blamed); raise the ceiling to verify a report that large. The re-analysis is also clamped to the report's recorded file-count and per-file caps, never above the verifier's.

On a server, the re-analysis is clamped to what the request reserved. A verification takes the same memory permit an analysis takes, and the replay IS an analysis — so muundo-server and muundo-mcp cap its source bytes, file count and per-file size at their own per-analysis ceilings rather than at MUUNDO_MAX_VERIFY_TOTAL_BYTES. A report recorded under wider ceilings still verifies; it stops at the server's clamp, and the answer is verification_workload_too_large because the ceiling is the verifier's and not the report's fault. On the command line the two numbers are the same, so nothing changes there.

A verification that is stopped answers cancelled, exit 3 — the same code an interrupted analysis answers, because it is the same event. The hashing phase checks for it inside the read loop and not only between files: one file can be the whole workload, and a check between files would leave it running after the caller had gone. A command line has nobody to disconnect and no timeout, so only the HTTP and MCP surfaces can produce it (HTTP answers 499).

The server ceiling covers three things, and they are added up at startup: analysis memory, the read buffers of responses being streamed, and the spool.

A response that a slow client is still reading holds a read buffer, and those bytes stay charged against the ceiling for as long as they exist — including after the server has finished reading the file, while the data waits for a client that is not collecting it. So a room full of slow clients cannot push the process past MUUNDO_MAX_SERVER_BYTES. Under pressure the server reads in smaller batches; at the ceiling it refuses a response rather than exceeding it.

The idle timeout is the primary guard; the total one is deliberately generous, because a legitimate report over a very large tree takes a long time to send.

Other settings:

Variable What it does
MUUNDO_API_KEY Requires callers to present it. Unset means no key is configured — never a key whose value is the empty string
MUUNDO_BIND_ALL Listen on all interfaces instead of loopback. Refused at start-up unless MUUNDO_WORKSPACE_BASE is also set
MUUNDO_CORS_ORIGINS Allowed browser origins. * means any origin (no credentials are ever allowed); an unknown value is skipped, never a boot panic
MUUNDO_WORKSPACE_BASE Confines an analysed root to a directory
MUUNDO_TS_STATEFLOW_STRATEGY Default TypeScript state-flow extraction

MUUNDO_WORKSPACE_BASE is the one to set before exposing the server: without it, root is any path the process can read, and a relative root is relative to wherever the server was started. Because that pairing is what keeps the server confined, MUUNDO_BIND_ALL without MUUNDO_WORKSPACE_BASE is refused at start-up rather than left as a silent host-wide exposure.

Put it behind a reverse proxy

The server speaks plain HTTP and has no TLS of its own. The X-API-Key it checks crosses the wire in clear, so anything past loopback belongs behind a reverse proxy (nginx, Caddy, a cloud load balancer) that terminates TLS. The proxy is also the right place for per-client rate limiting and for refusing oversized or slow requests; the server bounds request bodies, analysis concurrency and — since it reads its own header-timeout — a stalled connection, but a proxy in front is the assumed deployment. There is no authorization beyond the single API key: every holder of the key can analyse every path inside MUUNDO_WORKSPACE_BASE, so a deployment serving more than one tenant runs one instance (or one base) per tenant, or validates root in the proxy.

MCP server

muundo-mcp exposes the engine as tools an AI agent can call, over the Model Context Protocol (stdio transport). An MCP client spawns the binary and speaks JSON-RPC on its stdin/stdout; register it like any other stdio MCP server:

{
  "mcpServers": {
    "muundo": { "command": "muundo-mcp" }
  }
}

Tools: analyze (full report), fragility, hotspots (top_n, default 10), metrics (by entity qualified name), verify, snapshot, query, info. Each takes a root path (except info); analyze also takes optional languages, include_call_graph, include_doc_coverage.

snapshot and query are how an agent stops paying for the same analysis. snapshot analyses a tree and keeps it, naming it with ref; query then answers from the index beside it — entity, callers, callees, file, summary, freshness. Measured: 85 ms on a 259 000-line tree, where re-analysing to answer the same question takes 3.90 s. The store is the server's own, so no path travels in a tool call.

verify is the one an agent needs most and could not reach. Every other tool answers "what is in this code"; this one answers "is what I was told still true", which is the question an agent acting on an earlier answer has. It takes root, report (a path to the report JSON) and an optional workspace_base, and answers the verdict described under What a verify verdict says.

The report path goes through the same confinement as root: with a boundary configured, a report outside it is refused before it is opened. A verification costs what an analysis costs — the replay is one — so it takes the same memory permit as every other tool, and cancelling the request stops the replay.

It reuses the engine — the same path containment, resource limits and recursion guards as the CLI and the HTTP server; it re-implements none of them. A tool failure comes back as an MCP tool error (isError: true) carrying the engine's stable code and a path-free message, never a filesystem path.

Confinement — secure by default. Every root an agent names is confined beneath a directory unless the operator opts out of confinement in so many words. Set MUUNDO_WORKSPACE_BASE to confine to that directory (exactly as the HTTP server does). With neither variable set, root is confined to the current working directory — the secure default, not open access. To let a tool analyse any path the process can read, set MUUNDO_ALLOW_UNRESTRICTED=1 and no base; that, and only that, is unrestricted. It is resolved once at start-up: setting both is contradictory, an empty or non-resolving base or an MUUNDO_ALLOW_UNRESTRICTED that is not 1 is a start-up failure (non-zero exit) — never a silent fall back to opening every readable path. A configured boundary rejects a root that is a sibling or a parent of it.

Requests are read as size-capped frames and tool results are size-capped (a report too large to return comes back as a tool error pointing at a narrower analysis — fewer languages, fragility, or a subdirectory root). The input loop stays responsive while an analysis runs AND while its response is encoded: both the analysis and the serialisation-and-escaping of a large report run on a blocking worker, never on the event loop, so ping, tools/list and notifications/cancelled are answered or acted on immediately throughout. The response line is built in a single buffer (envelope plus escaped body, never a second full-size copy); a request cancelled mid-encode stops within a bounded interval. Analyses run under a concurrency ceiling, and a malformed JSON-RPC frame — invalid UTF-8, a missing 2.0 marker, a non-string method, an illegal id — is answered with the matching JSON-RPC error, never rewritten or acted on. Diagnostics go to stderr; stdout carries only protocol.

The handshake is required. A client must complete the MCP lifecycle before using tools: send initialize — whose parameters are a closed, fully-typed object (protocolVersion, capabilities, clientInfo), rejected with invalid_params if an array, incomplete or wrong-typed — then the notifications/initialized notification. tools/list or tools/call before that is refused as invalid_request, a second initialize is refused as a duplicate, and only ping and cancellation are answered in the meantime. ping and tools/list take no parameters and reject any.

A notification with the wrong parameters does nothing. A notification carries no id and is never answered, so a malformed one cannot be rejected — it is silently ignored, which means its side effect must not happen. The parameters of notifications/initialized and notifications/cancelled are parsed to the protocol shape first, and only a clean parse acts. notifications/initialized takes no operative parameters (an optional _meta object is the only field allowed); an unknown key, or a _meta that is not an object, leaves the handshake un-advanced. notifications/cancelled needs a requestId that is a string or a number (its optional fields are reason and _meta); an array or object id, an unknown key, or a missing requestId cancels nothing. Neither accepts arbitrary arrays or objects in place of the typed shape.

Weighted memory admission. Counting only how many analyses run at once is not enough: an analysis retains far more than the source it reads (tree-sitter parse trees and the extracted graphs amplify the input, measured ~150× on dense code), so a couple of maximum-size inputs can exhaust RAM. The MCP server holds the same weighted budget the HTTP server does. It reserves a fixed slice for the per-worker response encoders, the output queue, and the pending-job queue, then sizes a memory semaphore from what remains so that the worker count's worth of worst-case analyses fit underneath — reducing each tool's effective max_total_bytes to match, and holding each reservation until the report has been encoded and dropped. MUUNDO_MCP_MAX_MEMORY_BYTES (default 4 GiB) sets the ceiling and MUUNDO_RSS_AMPLIFICATION (default 160) the factor; a budget too small to fit the fixed buffers plus one meaningful analysis is a start-up failure, not a server that admits requests it cannot serve.

A queued call is validated and converted to a small typed job before it takes a queue slot: a malformed call is refused at once, and a waiting job retains only its parsed arguments (a path, a few language names, booleans, one integer) — not the raw JSON argument tree. Those pending bytes are admitted against their own aggregate budget and counted inside the ceiling above, so a burst of queued requests cannot grow memory past it. Request frames are capped at 64 KiB, which is already far more than any valid call needs.

Client SDKs

Thin HTTP clients for the analysis server, one per language jagora targets, live under sdks/. Each wraps the same three endpoints (/health, /info, /analyze), sends the X-API-Key header, and raises a typed error carrying the server's stable reason code; the report comes back as parsed JSON, versioned by its version field. Hand-written SDKs ship for Python, TypeScript/JavaScript, Rust, Go, C# and Java; Kotlin on the JVM uses the Java SDK directly, and any other language (C, C++, Ada, …) generates from sdks/openapi.yaml, which is the single source of truth for the API. Full contract, per-language usage and the codegen commands are in sdks/README.md.

Building

cargo build --release --workspace   # muundo, muundo-server, muundo-mcp
cd python && maturin build --release

Layout

muundo/
├── core/      # muundo-core — the parser, graph, metrics
├── cli/       # muundo-cli  — provides the `muundo` binary
├── server/    # muundo-server — the HTTP API
├── mcp/       # muundo-mcp — the MCP server (stdio)
├── python/    # the PyO3 module
├── docs/      # everything published; the site is built from here
└── tests/

Licence

Apache-2.0.