Manifest reference
Every key .mmz/config.yaml can declare, generated from the JSON Schema the binary ships.
mmz reads the nearest .mmz/config.yaml, searching upward from the working directory, plus whatever it names in a top-level imports: list — see composing a manifest from imports. The directory holding .mmz is the project root, and every relative path in the manifest resolves against it, except an imports: entry itself, which resolves against the importing file’s directory.
# yaml-language-server: $schema=../../../schema/mmz.schema.json
# The docs fixture's manifest. www/generate.sh runs the real `mmz` against a
# throwaway copy of this project and captures the output verbatim, so every
# transcript on the docs site is this file's actual behaviour rather than a
# hand-typed illustration.
#
# The two commands are deliberately fast and dependency-free (`sh` and `jq`,
# both already on the docs build's PATH): a fixture whose checks took a minute
# would make every docs build take a minute, and a fixture needing a toolchain
# would make the site unbuildable wherever that toolchain is missing. Between
# them they exercise all four things a rule can declare — scopes, a probe,
# declared outputs, and tags.
scopes:
# The pipeline's real inputs: the data it reads and the scripts that read it.
data: ["data/*.json"]
bin: ["bin/*.sh"]
probes:
# Both scripts shell out to jq, so a jq upgrade must bust both rules. jq is
# not a file in this project, which is exactly the gap a probe closes.
jq-version:
run: jq --version
on_hit: "mmz: skipped {cache:command} (inputs unchanged)"
commands:
# A verdict command: it passes or it fails, and leaves nothing behind.
- name: ./bin/validate.sh
inputs: [data, bin, jq-version]
tags: [gate]
# A producer command: its record is void once the artifact it promised is
# gone, however unchanged the inputs are.
- name: ./bin/report.sh
inputs: [data, bin, jq-version]
outputs:
- out/report.txt
tags: [gate, report]The manifest is validated at load: command names must be non-empty and unique, every inputs: entry must name a defined scope or probe (and no name may be both), outputs must be literal paths, and strict names must be known. A manifest that does not validate is an error, never a warning — see fail closed.
Run mmz --schema for the schema itself; it is the source this page is generated from, and it is what the $schema line mmz --init writes points at:
https://raw.githubusercontent.com/mlavrinenko/mmz/v0.13.0/schema/mmz.schema.json
$schema URL is pinned to the v#PKG_VERSION tag rather than main, so a project keeps validating against the schema its mmz was built for. Two projects on different mmz versions each validate correctly.Top level
The seven keys a manifest can declare.
| Key | Type | Default |
|---|---|---|
imports | array | — |
scopes | object | — |
probes | object | — |
commands | array | — |
gitignore | boolean | true |
cache_dir | string | ".mmz/cache" |
strict | array | — |
on_hit | string | — |
probe_shell | array | ("sh", "-c") |
imports
Paths to other YAML files whose scopes, probes and commands are merged into this one. A directory entry expands to every *.yaml and *.yml file directly inside it (not recursive), sorted lexically; a missing file or directory is an error, an empty directory is not. Relative paths resolve against the directory of the file that names them, not the project root, so a fragment can reference a sibling fragment; absolute paths (store paths included) are used as written. A fragment may declare imports of its own. A key declared in two files is an error naming both; cache_dir, gitignore, strict and on_hit may only be set in the root manifest.
What lets a generated fragment and a hand-written manifest coexist instead of one clobbering the other. A duplicate key across files is always an error, never last-wins — a stale hand-written rule silently replacing a regenerated one is exactly the false green this tool exists to prevent. Order matters for commands: the importing file’s own rules get first crack at an invocation, so nothing generated can shadow them.
scopes
Named glob sets, each referenced by command rules. A scope is either an array of patterns, or an object naming those patterns under globs alongside an exclude list and a per-scope gitignore override.
Declared once, referenced by many rules, so a shared input path lives in one place. Globs follow the common convention: * stays within a directory, ** crosses them.
A scope is the unit of over-declaration, and over-declaring is the safe direction — a scope that is too broad costs an unnecessary re-run, while one that is too narrow buys a wrongly-fresh rule. When in doubt, widen it.
probes
Named sources of input bytes. A rule's inputs references a probe by name exactly as it references a scope, and the hash of what the probe produced joins that rule's input digest — which is how a rule depends on part of a file (one node of a flake.lock, one recipe body out of a Justfile) or on something that is not a file at all (a toolchain fingerprint). A probe reads either a file (`file`, which needs a `json` or `ast` selector) or the stdout of a command (`run`); the two are mutually exclusive, and a probe must declare one. One namespace with scopes: a probe sharing a name with a scope is a manifest error. A probe is resolved once per mmz invocation, however many rules name it. Weigh the trade before reaching for one: a wrong scope costs time, a wrong probe can lie.
How a rule depends on something that is not a whole file. Read the trade before reaching for one — a wrong scope costs time, a wrong probe can lie — and note that mmz validates a probe’s exit status, never its meaning.
Reach for file: + json: first. It is the only shape that costs nothing: no process, no ambient tool, no shell quoting. run: is for what genuinely is not on disk.
commands
Ordered command rules; the first token-prefix match wins.
Ordered rules; the first token-prefix match wins, so specific rules go before general ones. The cache identity is the matched rule, not the argv, which is what makes rule granularity a design decision rather than an accident.
gitignore
Skip git-ignored paths when expanding globs.
On by default because an input set should never contain build output. Explicitly listed literal paths are always kept, .git is never traversed, and symlinks are not followed.
cache_dir
Directory for throwaway cache records, relative to the project root (the directory holding .mmz); an absolute path is used as-is. Must be git-ignored.
Derived, throwaway state — never commit it. --init writes a .mmz/.gitignore that covers the default location; point this elsewhere and it is on you to ignore the new one.
strict
Runtime cases mmz errors on instead of falling back to passthrough. Omit the key for all cases (the default); list a subset to relax the rest; use [] to fall back everywhere.
The fail-closed switch. Omit the key and mmz errors rather than guessing on both cases; relax a case and the matching invocation runs unmemoized instead of stopping. Relaxing no_inputs is the riskier of the two: a rule that resolves to no files is usually a scope that stopped matching, not a rule that genuinely has no inputs.
on_hit
Message printed to stderr when a command is skipped (a cache hit). Macros: {cache:<field>} substitutes a field from the matched rule's cache record (e.g. command, ran_at, input_digest). An empty string prints nothing; a command's own on_hit overrides this.
A skip is invisible unless something says so, and a silent gate is one nobody trusts. {cache:<field>} pulls a field straight from the record that caused the hit, so the note can name what it is standing on.
probe_shell
The argv every probe's run line is executed by, with the line appended as one final argument. Defaults to ["sh", "-c"]. Pin it to run probes under the project's own tooling instead of whatever PATH the caller had — e.g. ["direnv", "exec", ".", "sh", "-c"] or ["nix", "develop", "--command", "sh", "-c"]. The run lines themselves are unchanged; only the process interpreting them differs.
A probe resolves its commands through whatever PATH the caller had, so the same probe can report two answers on one worktree — a project shell and a bare login shell disagreeing about a tool version is enough. Pinning the argv makes a probe measure the project’s tooling instead of the operator’s. Root-only, and it changes nothing about the run: lines themselves.
A command rule
Each entry of the commands: array.
| Key | Type | Default |
|---|---|---|
commands[].name | string | — |
commands[].inputs | array | — |
commands[].exclude | array | — |
commands[].outputs | array | — |
commands[].match | string | "prefix" |
commands[].tags | array | — |
commands[].on_hit | string | — |
commands[].ttl | string | — |
commands[].name
Matcher and the cache identity. A single {scope} macro (one whitespace token, possibly embedded like --file={scope}) fans the rule over that scope's files: one per-file cache record per matched file, each keyed by the expanded name and scoped to that file plus the shared `inputs` pins.
Both the matcher and the cache key. Split a rule or narrow its matcher when one rule conflates invocations with genuinely different inputs.
commands[].inputs
Input names, each either a scope (whose globs union into this rule's file set) or a probe (whose stdout hash joins its digest) — one namespace, written identically. For a parametric rule these are the shared pins added to every per-file record; keep the fan scope out of them.
Scope names, probe names, or both — one namespace, so a reader never has to guess which kind a name is, and an entry that is neither is refused at load. A rule whose only input is a probe still has inputs: it is memoized, not no-inputs.
commands[].exclude
Patterns left out of every file this rule's scopes resolve to, its {file:scope} domain included, on top of each scope's own exclude. A path is excluded when it, or any directory above it, matches one, so an excluded directory is never walked, listed or hashed. Probes are untouched.
The rule-level twin of a scope’s exclude, for a path one rule must not hash while the other rules naming the same scope still do. Probes are not affected.
commands[].outputs
Literal artifact paths a successful run of this command is expected to produce, relative to the project root. The rule is fresh only when its inputs still hash the same AND every declared output exists, so an artifact deleted out from under a record (cargo clean, a fresh clone, a pruned target/) voids that record instead of leaving the rule fresh with nothing to show for it. Existence only — an output is never hashed. Each path is stat-ed, never walked, so the gitignore filter never applies to it and an artifact under an ignored tree needs no opt-out. Patterns are rejected at load: a glob here would never match. A run that exits 0 without producing a declared output is a hard error (exit 5) and records nothing.
Declare these for a rule that produces something. A verdict command (fmt --check, a linter) leaves nothing behind and needs none; a producer command’s record can be undone without touching an input, and this is what lets mmz notice.
commands[].match
How name matches argv. prefix (default): name's tokens are a leading slice of the command. exact: the whole command equals name's tokens.
exact only ever narrows a rule, so it cannot cause a wrongful skip: an invocation it no longer matches falls through to the no-match case. Reach for it when trailing arguments change the real work — cargo test and cargo test --release as separate identities.
commands[].tags
Free-form labels for filtering with `mmz --is-fresh --tag <tag>` (and `--status --tag <tag>`); repeat --tag to require every listed tag (AND). A rule with no tags never matches a --tag filter. Each tag is trimmed and blank entries are dropped; duplicates within one rule are rejected.
What lets one manifest hold a gating subset alongside memoized commands a gate should ignore, instead of splitting the manifest per concern.
commands[].on_hit
Overrides the top-level on_hit for this rule. An empty string suppresses the cache-hit notice.
Per-rule override of the global note. Set it to the empty string to silence one noisy rule without silencing the rest.
commands[].ttl
How long a successful record stays fresh, for a command whose verdict depends on the world as well as its inputs (an advisory database, a registry). One positive whole number and one unit: s, m, h or d — 30m, 12h, 7d; zero, compounds like 1h30m, and anything else are refused at load. A pass whose age has reached the ttl is expired: the command re-runs, --is-fresh fails, and --status reports `expired`. Age is measured from the record's ran_at against the process clock (MMZ_NOW pins it). A rule with a ttl may declare no inputs at all, making time its only input; a ttl does not excuse declared inputs that resolve to nothing. No ttl: a pass never expires.
For a verdict the world can change behind the inputs’ back — npm audit against an advisory database, a check that every image a compose file names still exists. Without it one pass of either is fresh for as long as the lockfile is. Per rule only, deliberately: a default would put every input-determined rule on a timer for nothing, and a YAML anchor shares one value across the few that want it.
A probe
Each entry of the probes: map.
| Key | Type | Default |
|---|---|---|
probes[].run | string | — |
probes[].file | string | — |
probes[].json | string | — |
probes[].ast | string | — |
probes[].capture | array | — |
probes[].lang | string | — |
probes[].allow_empty | boolean | false |
probes[].run
The command line whose stdout is the probe's bytes, run by `sh -c` from the project root with stdin closed. Mutually exclusive with `file`. A non-zero exit is a hard error naming the probe, its exit code, and its stderr: mmz exits without consuming the output and without writing a record, so a failed command never reaches the hasher. A probe that cannot be spawned is the same error. Content correctness and determinism are the manifest author's, not mmz's — pin the ordering, strip the timestamps, and assert the shape inside the probe (jq -e, a schema check) so a bad shape becomes a non-zero exit, or hand the shape to `json` below and let mmz enforce it.
Executed by sh -c from the project root with stdin closed, so a probe waiting on input fails instead of hanging a gate. Resolved once per mmz invocation however many rules name it.
A run: line is a dependency on the ambient environment — the tools it calls must be installed, and must answer the same way here as on a colleague’s machine. That is the cost file: does not have.
probes[].file
A file to read, relative to the project root — the same base scope globs resolve against. Mutually exclusive with `run`, and requires a selector — `json` for a document, `ast` for source code. This is the fully in-process shape: no process is spawned, nothing has to be on PATH, and there is no shell quoting to get wrong, so a rule can depend on one field of a lockfile that a scope could only name whole. A file that is missing or unreadable is a hard error naming the probe and the path. Hashing a whole file is what a scope is for, which is why a bare `file` with no selector is refused rather than accepted as a second spelling of one.
Sourced from the repository rather than from a process, which is what makes per-input tracking cheap enough to be routine: eleven lockfile-sourced probes are eleven file reads, where eleven --version calls are eleven spawns on every mmz --is-fresh.
The path is not filtered by gitignore, unlike a scope’s globs — a probe names one file explicitly, and a rule pinned to a generated lockfile is a thing people legitimately want. Naming a file that is not there is an error, not an empty input.
probes[].json
A jq program selecting out of the probe's bytes, run in-process by an embedded jq engine — no jq on PATH. mmz hashes its own canonical rendering of the selected value, object keys sorted at every depth and array order preserved, so a renderer's key order is structurally not an input and nobody has to remember jq -S. Multiple outputs (`.a, .b`) join one per line. A selection that measured nothing — no output at all, or a lone null — is a hard error, for the same reason jq -e exists: a probe tracking null reports one digest whatever the document does, leaving the rule fresh forever against an input nobody is measuring. `false` is a value and passes. Bytes that are not exactly one JSON value, a program that does not compile, and a program that raises against the document are each a hard error naming the probe.
jq, not a narrower path syntax, and deliberately: a manifest key’s meaning must not change under a reader in a later version, and the probes in this repo already use , and with_entries(select(…)). A spelling that could not express what people already write would have had to grow into jq anyway.
What it buys beyond narrowing is that key order stops being an input structurally. Piping through jq hashes some renderer’s bytes, so forgetting -S makes a tool upgrade look like a busted rule; here mmz owns the rendering and there is nothing to forget.
probes[].ast
An ast-grep pattern matching over the probe's bytes parsed as source code, run in-process by a bundled tree-sitter grammar — nothing on PATH, no regex pretending to be a parser. Mutually exclusive with `json`. mmz hashes its own canonical rendering of every matched node — every token exactly, the whitespace between them dropped — so reformatting a signature is not an edit to it, while renaming one is. A match is a whole node unless `capture` narrows it to the metavariables the pattern named. Matches join one per line in document order, which is kept rather than sorted: order that comes from the document is content. A pattern the grammar can only recover into an error node is refused rather than left to match nothing, and a pattern that matched no node at all is a hard error for the same reason an empty json selection is. Which languages a build can parse is a compile-time choice — see `lang`.
For the inputs that are code rather than data. “This gate depends on the public API of lib.rs, not on its comments” has no other spelling: the closest a scope gets is hashing the whole file, which is the over-declaration probes exist to escape.
Reach for it only when the file has no structured view already. A tool that can print its own configuration as JSON is a json: probe, and that is the cheaper, steadier answer — a rendering names node kinds, so a grammar bump can move a digest that no edit moved. That is a false stale, which settles after one re-run; the trade is deliberate, because hashing the matched text instead would be steady across grammar bumps and blind to reformatting, and a digest that misses a real edit is the failure this tool exists to prevent.
probes[].capture
Which of `ast`'s metavariables are the input, written without the `$` — `[NAME, ARGS]` for a pattern spelling `$NAME` and `$$$ARGS`. Absent means the whole matched node, which is the default; the list narrows each match to the parts it names, and is the only way to keep a construct's signature and drop a body the grammar glued to it. The pattern still decides which constructs match, so a metavariable left out of the list is still what makes the pattern reach them. Each capture renders as `($NAME …)` wrapping the rendering of every node bound to it, and the list is sorted before hashing — it is the set of parts that matter, so retyping it in another order is not an edit. A name the pattern does not define is a hard error naming what it does define: an undefined name binds nothing and would contribute the same bytes whatever the source said, and `allow_empty` cannot be blamed for it because the matches are all there. An anonymous `$$$` or a `$_` binds nothing in ast-grep and cannot be named at all.
What makes the motivating example reachable rather than nearly reachable. A Rust signature stops being a node of its own once a body follows it, so the only pattern that matches a real function spans the body too — and without this key, “depends on the public API and not the bodies” had no spelling at all.
Read the pattern and the list as two different questions. The pattern decides which constructs are matched, so a metavariable left out of the list still earns its place there; the list decides which parts of each are hashed. Dropping $$$BODY from the pattern would not narrow the input, it would stop matching functions that have one.
The cost is that a probe naming three captures is more to read than one naming a pattern, and the win is confined to constructs whose grammar glues a signature to a body. Where a pattern can already stop at the boundary you care about, let it — the default is the whole matched node precisely because that is the answer most of the time.
probes[].lang
Which language `ast` parses the bytes as. Optional beside a `file` whose extension mmz recognises, required beside a `run` (a command line implies no language), and a manifest error without `ast`, which would be a key that silently does nothing. A grammar is not small — carrying every one would multiply the size of the binary — so each is a cargo feature and a stock build carries `rust` alone. Naming a language this build lacks is a hard error that names the feature flag to rebuild with; naming one mmz has no grammar for at all is a different error, because it needs a different answer. mmz never falls back to parsing an unknown file as something plausible.
A build-time fact, not just a manifest one. Grammars are large enough that shipping all of them would charge every user who never writes an ast: probe 41.2 MB, so a stock cargo install mmz parses Rust and each other language is a --features lang-<name> flag away.
That makes a manifest’s portability worth a thought: a probe naming a language your colleague’s mmz was not built with fails for them. It fails loudly, naming the flag, which is the same bargain a run: line already makes with the tools it calls — but unlike a missing tool, the fix is a rebuild rather than an install.
probes[].allow_empty
Accept an input that measured nothing: with `json`, a selection that yielded no value or only null; with `ast`, a pattern that matched no node; with neither, stdout that is empty or only whitespace. One key, one meaning. By default it is an error, because it is the cheapest catch for a selector that matched nothing.
Opt in only when empty output is genuinely a valid state. The default exists because an empty result is almost always a selector that matched nothing, and that is the cheapest bug in this whole surface to catch.
With json: it also accepts a lone null, which is the form “matched nothing” actually takes in jq — .a.b on a document without them is a successful selection of nothing at all.
A scope, object form
A scope spelled as an object rather than a bare array of patterns.
| Key | Type | Default |
|---|---|---|
scopes[].globs | array | — |
scopes[].exclude | array | — |
scopes[].gitignore | boolean | — |
scopes[].globs
The scope's patterns. At least one is required; use the array form when there is no override to declare.
The object form’s pattern list, required and non-empty. An object without it is a manifest error rather than a scope that quietly matches nothing.
scopes[].exclude
Patterns left out of this scope's globs and literals. A path is excluded when it, or any directory above it, matches one, so an excluded directory is pruned during the walk: nothing under it is listed, hashed or read. Same glob syntax as globs. Applies to this scope only; a sibling scope in the same rule still sees the path.
For a tree a broad glob reaches but the rule must not depend on, such as a cache another gate rewrites. Matching a directory drops everything under it, and the walk never enters it, so a file deleted there mid-run cannot fail the hash. Reach for it over a .gitignore line when git should still track the path.
scopes[].gitignore
Overrides the manifest-level gitignore for this scope only; omit to inherit it. Set false for a scope naming build artifacts: they live in git-ignored paths by definition, so under the filter the scope resolves empty and the rules referencing it are fresh forever.
The per-scope override, and the one thing the object form exists for. Keep it at the scope that names the artifact: flipping the manifest-level setting instead would drag every sibling scope through target/ and any other ignored tree, which is both slow and a source of spurious cache busts.