Quickstart

Install mmz, scaffold a manifest, and watch a command skip itself.

Install

cargo install mmz

Or download a pre-built binary from the latest release: one archive per target, .tar.gz everywhere but Windows, each holding the binary under its plain name so an extract leaves a runnable mmz behind. SHA256SUMS beside them covers the set. With Nix, nix run github:mlavrinenko/mmz runs it without installing anything.

Check what you got:

mmz 0.13.0 (1 ast lang)

The count in the brackets is the languages this build can parse with an ast: probe. A grammar is not small, so which ones a binary carries is settled when it is built, and each release publishes two:

BuildParses
mmz-<target> — also what cargo install mmz and a bare nix run give youRust, the one grammar the suite exercises
mmz-full-<target> — also nix build github:mlavrinenko/mmz#fullevery language mmz has a grammar for, at about eight times the size

Anything in between is a cargo install mmz --features lang-python,lang-go away, and a probe naming a language your build lacks fails saying so, naming the flag. Nothing is ever parsed as the wrong grammar to cover for it.

Scaffold a manifest

Run mmz --init in the root of the project you want to memoize:

wrote .mmz/config.yaml

Two files, both under one directory. The manifest is tracked; the .gitignore beside it ignores the cache, so your project gains one entry and its root .gitignore stays untouched.

# yaml-language-server: $schema=https://raw.githubusercontent.com/mlavrinenko/mmz/v0.13.0/schema/mmz.schema.json
# .mmz/config.yaml — memoized command runner config.
# Prefix a command with `mmz`; it is skipped when the matched rule's inputs are
# byte-for-byte unchanged since the command last succeeded.

# Named glob sets, declared once and referenced by commands. `*` stays within a
# directory; `**` crosses directories.
scopes:
rust: ["**/*.rs", "Cargo.toml", "Cargo.lock"]

# Ordered rules. The first whose name is a token-prefix of the command wins.
# Set `match: exact` on a rule to match only the bare command (no extra args).
commands:
- name: cargo test
inputs: [rust]

# Named inputs that are not whole files, hashed into the input digest of every
# rule naming them in `inputs` — how a rule depends on part of a file, or on
# something that is not a file. One namespace with scopes: a probe may not
# share a name with one. Weigh the trade before reaching for this: a wrong
# scope costs time, a wrong probe can lie.
#
# `file:` + `json:` is the shape to reach for first. mmz reads the file and
# runs the jq program in-process — no shell, nothing needed on PATH — and
# hashes its own canonical rendering of the result, so object key order is
# never an input and there is no `jq -S` to forget.
# probes:
# nixpkgs-input:
# file: flake.lock
# json: '.nodes["nixpkgs"]["locked"]["narHash"]'
# commands:
# - name: nix flake check
# inputs: [nixpkgs-input]
#
# `run:` is for what genuinely is not on disk; it may carry a `json:` too, to
# select out of the command's stdout instead of a file. A non-zero exit, a
# failed spawn, an unreadable file, bytes that are not JSON, or a selection
# that measured nothing (unless `allow_empty: true`) is a hard error and
# records nothing. A `run:` line resolves through the caller's PATH; set
# top-level `probe_shell` (e.g. ["direnv", "exec", ".", "sh", "-c"]) to pin
# the environment it is measured in.
# probes:
# fmt-recipe:
# run: just --dump --dump-format json
# json: '.recipes["fmt-check"]'
# commands:
# - name: just fmt-check
# inputs: [rust, fmt-recipe]

# A single {scope} macro in a name fans the rule over that scope's files: one
# per-file cache record, each keyed by and scoped to its own file plus the
# shared `inputs` pins. Best for commands that read only the one file (a
# per-file lint/format); a whole-crate command under-declares this way.
# scopes:
# lint-targets: ["src/**/*.rs"]
# commands:
# - name: "ruff check {lint-targets}"

# Optional labels on a rule. `mmz --is-fresh --tag <tag>` (and
# `mmz --status --tag <tag>`) then gates only the rules carrying every listed
# tag, instead of every rule in the manifest — so one manifest can hold both
# a gating subset (wired into a pre-push hook or `just check`) and other
# memoized commands (e.g. bench) that a gate should ignore. Untagged rules
# never match a --tag filter; repeat --tag to require more than one.
# commands:
# - name: cargo test
# inputs: [rust]
# tags: [gate]
# - name: cargo bench
# inputs: [rust]
# tags: [bench]

# Printed to stderr when a command is skipped. `{cache:<field>}` pulls a field
# straight from the cache record (command, ran_at, input_digest, ...). Set it per
# command to override, or to "" to silence that one.
on_hit: "mmz: skipped {cache:command} (inputs unchanged)"

# Directory for throwaway cache records, relative to the project root (the
# directory holding .mmz). The generated .mmz/.gitignore already ignores it.
# cache_dir: .mmz/cache

# Skip git-ignored paths when expanding globs (default true).
# gitignore: true

# Runtime cases mmz errors on instead of falling back. Omit for all (the safe
# default); list a subset to relax the rest; use [] to fall back everywhere.
# strict: [no_match, no_inputs]

The $schema line is pinned to the v#PKG_VERSION tag — the version of mmz that wrote it, not main — so a project keeps validating against the schema its mmz was built for even when two projects pin different versions. Editors that honour yaml-language-server will complete and validate the file as you type.

Declare a rule

A rule is a command plus the inputs it depends on. Inputs are named scopes of globs, declared once and referenced by as many rules as need them:

scopes:
rust: ["**/*.rs", "Cargo.toml", "Cargo.lock", "rust-toolchain.toml"]

commands:
- name: cargo test
inputs: [rust]

name is both the matcher and the cache identity. It matches by token prefix, so this rule covers cargo test and cargo test --workspace alike, but not cargo build.

The one thing to get right: a rule’s scopes must cover every file any matching invocation could depend on. Too broad costs a re-run; too narrow skips work that needed doing. When in doubt, widen the scope.

Run it twice

Wrap the command wherever memoization is wanted — a Justfile recipe line, a shell, a git hook. The first run does the work:

validate: 4 orders, 2 customers OK

The second does not:

mmz: skipped ./bin/validate.sh (inputs unchanged)

Nothing ran. mmz resolved the rule’s inputs, hashed them, found the digest identical to the one the last successful run recorded, printed the on_hit note, and exited 0.

Touch any file in the data or bin scopes — or upgrade jq, which the manifest’s probe pins — and the next run does the work again.

See what it is standing on

RULE               STATE  AGE
./bin/validate.sh fresh 0s ago
./bin/report.sh fresh 0s ago

--status is read-only: it resolves and compares, and runs nothing. When a rule is stale and you want to know which input moved, --status=json carries every resolved input, its hash, and what the record saw.

Next