mmz 0.6.0

memoized command runner
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
# mmz

[![CI](https://github.com/mlavrinenko/mmz/actions/workflows/ci.yml/badge.svg)](https://github.com/mlavrinenko/mmz/actions/workflows/ci.yml)
[![crates.io](https://img.shields.io/crates/v/mmz.svg)](https://crates.io/crates/mmz)
[![License: MIT](https://img.shields.io/crates/l/mmz.svg)](LICENSE-MIT)

memoized command runner

Prefix any command with `mmz`. When the matched rule's declared inputs are
byte-for-byte unchanged since the command last succeeded, `mmz` skips it and
exits 0. Otherwise it runs the command, streams its output, and records the
result on success.

It is not a build system: no task ordering, no dependency graph, no artifact
replay, no remote cache. It answers one question per invocation — is this rule's
work still done, i.e. are its inputs unchanged since it last passed and are the
artifacts it declared still there? See [Non-goals](#non-goals).

## Install

```bash
cargo install mmz
```

Or download a pre-built binary from the
[latest release](https://github.com/mlavrinenko/mmz/releases/latest).

## Usage

```
mmz <command> [args...]      run a command, skipping it when its inputs are unchanged
mmz --init                   write a starter .mmz/config.yaml in the current directory
mmz --status [--tag t]...    show each rule's freshness as a table
mmz --status=json            the same as JSON, with each rule's inputs and hashes
mmz --status=json-schema     print the JSON Schema for --status=json
mmz --is-fresh [--tag t]... [-- cmd]   exit 0 if cmd's rule (or every/tagged rule) is fresh
mmz --prune                  delete cache records whose rule no longer exists
mmz --schema                 print the config JSON Schema
mmz --version                print version
mmz --help                   print help
mmz -- <command> [args]      run a command whose name begins with a dash
```

Scaffold a manifest, then wrap commands wherever memoization is wanted — a
Justfile recipe line, a shell, a git hook:

```bash
mmz --init                # writes .mmz/config.yaml and .mmz/.gitignore
mmz cargo test            # skipped when the rust inputs are unchanged
mmz --status              # show each rule's freshness
```

`mmz` receives a clean argv vector (not a shell-expanded string), so matching is
robust and there is no nesting blind spot. Wrappers go outside it
(`chronic mmz cargo test`). There is no `just`/runner integration and no
`--no-memo`: a bare command without `mmz` is simply unmemoized.

## Manifest

`.mmz/config.yaml` — the nearest one, searching upward from the working
directory. Everything mmz needs lives under one `.mmz/` directory: the tracked
config plus a `.mmz/.gitignore` (written by `--init`) that ignores the cache,
so a project gains one entry and its root `.gitignore` stays untouched.

```yaml
# yaml-language-server: $schema=https://raw.githubusercontent.com/mlavrinenko/mmz/v0.1.0/schema/mmz.schema.json
scopes:
  rust: ["**/*.rs", "Cargo.toml", "Cargo.lock", "rust-toolchain.toml"]
# probes:                  # named commands whose stdout is an input
#   toolchain: { run: rustc -vV }
commands:
  - name: cargo test       # matcher and cache identity
    inputs: [rust]         # scope names, probe names, or both
#   outputs: [target/coverage/lcov.info]   # artifacts the run must leave behind
#   match: exact           # match only the bare command, no trailing args (default prefix)
#   on_hit: "tests fresh"  # per-rule cache-hit note, overriding the global one below
# cache_dir: .mmz/cache    # where throwaway records live (default; must be git-ignored)
# gitignore: true          # skip git-ignored paths when expanding globs (default)
# strict: [no_match, no_inputs]   # the default; list a subset to relax, [] to relax all
on_hit: "mmz: skipped {cache:command} (inputs unchanged)"   # stderr note on a hit
```

- `scopes`: named glob sets, declared once and referenced by many commands, so a
  shared input path lives in one place. Globs follow the common convention — `*`
  stays within a directory, `**` crosses directories. A scope is either an array
  of patterns or an object naming them under `globs` (see
  [Artifact scopes](#artifact-scopes-per-scope-gitignore)).
- `probes`: named commands whose stdout is hashed into the input digest of every
  rule referencing them — how a rule depends on part of a file, or on something
  that is not a file at all. One namespace with `scopes`. Weigh the trade first:
  see [Command inputs](#command-inputs-probes).
- `commands`: ordered rules. Each has a `name` (the matcher and cache identity),
  `inputs` (scope and probe names; the scopes' globs, unioned, are the rule's
  file set), and an optional `match` (`prefix`, the default, or `exact`; see
  [Matching](#matching)) and `outputs` (literal artifact paths the run must
  produce; see [Declared outputs](#declared-outputs)).
- `gitignore` (default `true`): glob expansion skips git-ignored paths, so build
  artifacts never enter an input set. Explicitly listed literal paths are always
  kept. The `.git` directory is never traversed; symlinks are not followed. One
  scope can override this for itself — see
  [Artifact scopes](#artifact-scopes-per-scope-gitignore).
- `cache_dir` (default `.mmz/cache`): directory for throwaway records, relative
  to the project root (the directory holding `.mmz`). Keep it git-ignored; set it
  to relocate state (e.g. `.cache/mmz`).
- `strict` (default: all): the runtime cases `mmz` errors on rather than falling
  back — `no_match` (no rule matches) and `no_inputs` (a matched rule resolves to
  zero files). Omit for both; list a subset to relax the rest; `[]` to relax all.
- `on_hit` (default: none): a line printed to stderr when a command is skipped.
  Embed `{cache:<field>}` to pull a field straight from the cache record
  (`command`, `ran_at`, `input_digest`, …). Set it per command to override the
  global note, or to `""` to silence one. `mmz --init` scaffolds a default.

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), and `strict` names must be known. Run `mmz --schema` for the full JSON
Schema.

`mmz --init` pins the `$schema` URL to the `v{version}` tag of the mmz that wrote
it, not `main`, so a project keeps validating against the schema its mmz was
built for even when different projects pin different mmz versions. `mmz --help`
and `mmz --version` report the running version.

## Artifact scopes (per-scope gitignore)

A scope value is normally an array of patterns. Spelled as an object it names
those patterns under `globs` and may pin `gitignore` for that scope alone:

```yaml
scopes:
  src: ["src/**"]              # array form: inherits the manifest-level setting
  lcov:
    gitignore: false           # this scope only
    globs: ["target/coverage/lcov.info"]
```

This is what a rule needs to depend on a build artifact. Artifacts live in
git-ignored paths by definition, so under the default filter such a scope
expands to nothing — and a rule whose inputs resolve to nothing is not an error
you notice, it is a rule that reports fresh forever. Opting the one scope out
makes the artifact a tracked input: regenerate it, and the rule goes stale.

Keep the override at the scope that names the artifact. Flipping the
manifest-level `gitignore` instead would drag every other scope through
`target/` and any other ignored tree, which is both slow and a source of
spurious cache busts. There is no per-glob override: one knob, at the level a
reader can see it.

A rule may mix both kinds of scope — each is expanded under its own setting, so
the sibling scopes in the same rule keep filtering. Absent means inherit; the
manifest-level default stays `true`. An object without `globs`, or with an empty
`globs` list, is a manifest error.

## Command inputs (probes)

A scope can only name whole files, so a rule that depends on *part* of a file
has to hash all of it — one recipe body in a `Justfile` busts every rule that
pins the `Justfile`. A `probes:` entry closes that gap: `run` is a command line,
its stdout is hashed, and `inputs:` references the probe by name exactly as it
references a scope.

```yaml
probes:
  fmt-recipe:
    run: just --dump --dump-format json | jq -c '.recipes["fmt-check"]'

commands:
  - name: just fmt-check
    inputs: [rust, fmt-recipe]
```

Nothing else about the rule changes: the probe's digest joins the rest of its
input digest, so `just fmt-check` re-runs when its own recipe body moves and
ignores every other recipe in the same file. The same shape covers a toolchain
fingerprint (`rustc -vV`), a resolved dependency set, or anything else a project
can print deterministically.

**Read this before reaching for one: a wrong scope costs time, a wrong probe can
lie.** Over-declaring a scope buys an unnecessary re-run — harmless. A probe that
prints the wrong bytes buys a wrongly *fresh* rule, which is the failure `mmz`
exists to prevent. So every way a probe can fail visibly is a hard stop:

- A probe that exits non-zero is an error naming the probe, its exit code, and
  its stderr. `mmz` exits 6 without consuming the output and without writing a
  record — a failed command never reaches the hasher.
- A probe that cannot be spawned is the same error.
- Empty stdout is an error by default; `allow_empty: true` opts in. It is the
  cheapest catch for a selector that matched nothing.

Content correctness and determinism are **yours, not `mmz`'s**. A probe that
prints valid but wrong output, or that varies run to run, is a manifest bug and
`mmz` cannot see it: pin the ordering, strip the timestamps, and assert the
shape inside the probe so a bad shape becomes a non-zero exit and hits the rule
above.

```yaml
probes:
  fmt-recipe:                                       # note the -e
    run: just --dump --dump-format json | jq -e -c '.recipes["fmt-check"]'
```

`jq -e` exits non-zero when its selector yields `null` or `false`, turning a
renamed recipe into a loud probe failure instead of a digest that quietly stops
tracking anything. `mmz` does not validate meaning, and will not learn to.

Mechanics: `run` is executed by `sh -c` from the project root (the directory
holding `.mmz`), with stdin closed so a probe waiting on input fails instead of
hanging a gate, and stderr captured for the failure message. A probe is resolved
once per `mmz` invocation however many rules name it, so eighteen rules sharing
one probe cost one process — the shape that matters, since a bare
`mmz --is-fresh` gates every rule and runs in git hooks. A declared probe that no
rule names is never run.

`inputs:` has one namespace: a probe sharing a name with a scope is a manifest
error, 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 has inputs — it is
memoized, not `no-inputs`. When a probe is what changed, `mmz --is-fresh` says
``probe `<name>` changed since it last passed`` rather than "inputs changed",
and `mmz --status=json` reports every resolved probe's current digest under
`probes` (with what each record saw under `cached.probes`).

## Declared outputs

A rule may list the artifacts its run produces. It is fresh only when its inputs
still hash the same **and** every declared output exists — a missing one makes
it stale whatever the inputs say:

```yaml
commands:
  - name: just cover
    inputs: [rust]
    outputs:
      - target/coverage/lcov.info
```

A record is a claim: this command exited 0 while its inputs hashed to H. For a
verdict command (`fmt --check`, `clippy`) that claim holds for as long as H
holds. For a producer command the claim carries a side effect, and the effect
can be undone without touching a single input:

```bash
mmz just cover   # runs, records H, writes target/coverage/lcov.info
cargo clean      # artifact gone; sources untouched, H unchanged
mmz just cover   # without `outputs`: fresh, skipped — and nothing to read
```

The record is not stale, it is void: the run it describes has been undone. Same
story for a fresh clone, a new worktree, or a pruned `target/`. This does not
replace the inputs — they stay the only evidence that an artifact matches the
sources. Outputs are the second way a record can stop being valid.

Existence only; `mmz` never hashes an output. The input digest already proves
that an existing artifact is the one those inputs produced, so hashing would buy
tamper detection alone (catching a hand-edited artifact) — a separate feature
with a separate cost, deliberately left out.

Outputs are literal paths relative to the project root, stat-ed directly and
never walked — a glob is a manifest error rather than a pattern that silently
never matches, and a `{scope}` macro is not substituted here. Because nothing is
walked, the `gitignore` filter never applies: an artifact under an ignored
`target/` needs no [artifact scope](#artifact-scopes-per-scope-gitignore)
opt-out, unlike the same path used as an *input*. A directory counts as present.

When a rule's record is voided, `mmz --status` says `missing-output` and names
the path in a `MISSING OUTPUT` column, `mmz --status=json` reports it as
`missing_output` (with the outputs the recorded run promised under
`cached.outputs`), and `mmz --is-fresh` fails with
``declared output `<path>` is missing`` rather than "inputs changed" — a wrong
reason there sends a reader to look at the wrong thing.

A run that exits 0 without producing a declared output is a hard error: `mmz`
prints the missing path, writes no cache record, and exits 5. Skipping the
record silently would leave a rule that quietly never hits again, which is the
exact failure this feature exists to end. A run that *fails* is untouched by
this — its own exit code is the story, and its failure is recorded as before.

## Tags

A command rule can carry `tags: [..]`; `mmz --is-fresh --tag <tag>` and `mmz
--status --tag <tag>` then narrow to rules carrying every listed tag, instead
of every rule in the manifest. Repeat `--tag`/`-t` to require more than one —
repeats AND together (there is no OR: call `mmz --is-fresh` once per tag for
that). A rule with no `tags:` never matches a `--tag` filter; a bare `mmz
--is-fresh` (no `--tag`, no command) still gates every rule regardless of tags.

```yaml
commands:
  - name: cargo test
    inputs: [rust]
    tags: [gate]
  - name: cargo bench
    inputs: [rust]
    tags: [bench]
```

`mmz --is-fresh --tag gate` here checks only `cargo test`; a bare `mmz
--is-fresh` still checks both. One manifest can then hold a gating subset
(wired into `just check` or a pre-push hook) alongside other memoized commands
a gate should ignore, instead of splitting `.mmz/config.yaml` per concern.

Tags are case-faithful, trimmed, and blank entries are dropped; declaring the
same tag twice on one rule is a manifest error.

## Matching

A rule's `name` is split on whitespace into tokens, and it matches when those
tokens are a prefix of the invoked argv: `cargo test` matches `cargo test` and
`cargo test --workspace`, but not `cargo build` and not the bare `cargo`.
Matching is on whole tokens, so `car` does not match `cargo`.

Rules are tried in manifest order; the first match wins. Order specific rules
before general ones. The cache identity is the matched rule (its `name`), not
the full argv, so you control granularity by how specifically rules are written:
split a rule or narrow its matcher when one rule conflates commands with
different real inputs.

Set `match: exact` on a rule to match only the bare command, no trailing args —
so `cargo test` and `cargo test --release` become separate cache identities. It
only narrows a rule, never causing a wrongful skip: an unmatched invocation
falls to the `no_match` case (error, or passthrough when relaxed).

## Parametric rules (per-file fan)

A single `{scope}` macro in a rule's `name` fans it over that scope's files: one
per-file cache record per matched file, without hand-listing each as its own
rule.

```yaml
scopes:
  lint-targets: ["src/**/*.rs"]
  rust-pins:    ["Cargo.toml", "Cargo.lock", "rust-toolchain.toml"]
commands:
  - name: "ruff check {lint-targets}"   # {scope} macro ⇒ parametric rule
    inputs: [rust-pins]                 # shared pins added to every record
```

`ruff check {lint-targets}` stands for `ruff check src/a.rs`, `ruff check
src/b.rs`, … — each a distinct cache identity. A record's inputs are the
`inputs` pins plus its own file, so editing `src/a.rs` busts only that file's
record. The macro is one whitespace token but may sit inside one
(`--file={lint-targets}`). `mmz` stays a prefix — you drive the loop
(`for f in src/**/*.rs; do mmz ruff check "$f"; done`).

The bound file must be a member of the scope (gitignore-filtered), so an
off-list path falls through to the no-match case rather than inventing a record.
`mmz --status` enumerates one row per expanded file, `mmz --is-fresh` gates one
verdict per expanded file (a bare `--is-fresh` over a parametric rule passes
only when every one of its expansions is fresh; a targeted `--is-fresh --
<command> <file>` gates the one expansion `<file>` binds to), `mmz --prune`
drops a record once its file leaves the tree, and two rules resolving to the
same expanded identity is an error, not a silently picked winner.

Per-file scoping is only honest when the command depends on that one file plus
the pins (a per-file lint/format/typecheck). A whole-crate command like `cargo
mutants -f {scope}` compiles siblings, so a sibling edit can leave a file's
record wrongly fresh — use the fan there knowing you trade correctness for speed.

## Correctness contract

The governing asymmetry, because the failure is silent:

- Under-declaring a rule's inputs → `mmz` skips a command that should have run →
  false green. Dangerous.
- Over-declaring inputs → an unnecessary re-run. Harmless.

So a rule's scopes must be a superset of every file any matching invocation
could depend on. When in doubt, broaden the scope. Toolchain sensitivity is
modeled as ordinary inputs: add `rust-toolchain.toml` or `flake.lock` to a scope
and a toolchain bump busts the cache. `mmz` trusts file content, not the ambient
environment — and a [probe](#command-inputs-probes) only shifts who is trusted,
from a file's bytes to a command's stdout, which is why its content is the
manifest author's to get right.

`mmz` fails closed: a missing or invalid manifest always errors, and unmatched
commands or matched rules with no inputs error too unless `strict` relaxes them
(then they run unmemoized). What `mmz` never does is skip a command whose inputs
it has not confirmed unchanged.

## State and exit codes

Records live in a git-ignored cache directory (`.mmz/cache` by default), one YAML file
per rule, written atomically (temp file + rename) so a crash or concurrent writer
never leaves a truncated record. Derived, throwaway state — do not commit it. A
record is fresh only when its `status` is `ok`, its content digest, format,
algorithm, and command all still match, and every output its rule declares is
still on disk; anything else re-runs. A record also remembers the outputs
declared when it was written, so a missing one is reported against the run that
promised it.

`mmz --status` shows each rule's verdict and the age of its record;
`mmz --status=json` adds every resolved input and its content hash so you can
`jq` out what changed, and `mmz --status=json-schema` prints its schema. Renaming
or removing a rule orphans its record; `mmz --prune` sweeps the unclaimed ones.

`mmz --is-fresh -- <command>` gates a command on its cache without running it:
exit 0 when its rule is already fresh, exit 1 otherwise. With no command,
`mmz --is-fresh` gates every rule at once. It is the inverse of wrapping —
`mmz <command>` runs a stale command, `--is-fresh` refuses it — so a git hook
can require that an expensive check was already run (and memoized) without
re-running it on the spot:

```bash
# pre-push: refuse the push if the VM checks were not run, but never run them here
mmz --is-fresh -- just check-vm || { echo "stale: run 'just check-vm' first" >&2; exit 1; }
```

A non-fresh gate prints one `mmz: \`<rule>\` is <state> (<reason>)` line per offender, then a single hint to re-run the listed commands under mmz (e.g. `mmz just check`) to record a pass — a standalone run is not tracked, so the rule stays stale or `never`. (A `no-inputs` offender names no run record.)

| Code | Meaning |
| ---- | ------- |
| 0    | fresh, skipped, or the command succeeded |
| 1    | `--is-fresh`: the targeted rule (or some rule) is not fresh |
| *n*  | the wrapped command's own exit code |
| 2    | usage error (empty invocation, unknown option, `--init` over an existing manifest) |
| 3    | strict refusal (no matching rule, or a matched rule with no inputs) |
| 4    | manifest missing or invalid |
| 5    | the command succeeded without producing a declared output; nothing recorded |
| 6    | a probe failed, could not be run, or printed nothing; nothing recorded |
| 70   | internal error |
| 127  | command could not be spawned |

## Non-goals

`mmz` follows the Unix philosophy — one thing, done right — so these stay out of
scope:

- Task orchestration: no execution order or dependency graph; use a task runner.
- Output replay: only the exit code is cached, never stdout, stderr, or
  artifacts. A declared `outputs` path is checked for existence, never stored,
  restored, or hashed.
- Automatic dependency tracing: no strace; scopes are declared explicitly.
- Remote caching: state is strictly local and throwaway.
- Deep runner integration: no plugins or hooks; `mmz` is a dumb CLI prefix.

## Library

`mmz` is published as a library crate too; the binary is a thin wrapper.
`mmz::run(&argv, cwd)` memoizes one invocation and returns its exit code;
`mmz::status`, `mmz::prune`, and `mmz::Manifest` cover the rest.

```rust
let argv = vec!["cargo".to_owned(), "test".to_owned()];
std::process::exit(mmz::run(&argv, std::path::Path::new("."))?.into());
```

## Dogfooding

`mmz` memoizes its own checks: [`.mmz/config.yaml`](.mmz/config.yaml) declares the rules and the
[`Justfile`](Justfile) `check` recipe wraps `cargo test`, `cargo clippy`,
`cargo fmt`, and `cargo machete` with the locally built binary, so a no-op
`just check` skips them.

## Development

Prerequisites: [Nix](https://nixos.org/) with flakes enabled.

```bash
direnv allow         # or: nix develop

just check           # fmt + clippy + tests + file-size + drift check (memoized)
just build
just test
just cover           # code coverage (70% minimum)
just fmt             # format code
```

See [CONTRIBUTING.md](CONTRIBUTING.md) for coding conventions.

## License

MIT