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:
Those are the exact strings the languages option accepts. The authoritative
list is what your build reports:
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>:
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 Xblocks; 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.listandworkspaces.listare two names, and the index tells apart the elements of one array, but six separatenextSteps: [{ 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¶
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,removedorchanged— two declarations sharing a name are two declarations; - each
changedentry carriesline_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}]
}
ofis thesnapshot_idof 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 Ncaps 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:
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.