| id | 103 |
|---|---|
| title | Build target staleness and dependency tracking |
| status | 🔲 |
| summary | Make `<?build?>` Make/Bazel-style: declare inputs per directive, hash them with the recipe spec, cache the hash per output, and rebuild only stale targets. Adds `--force` and `--check-stale` flags to `mdsmith build`. Without this, "build system" is aspirational; every run does a full rebuild. |
| model | opus |
mdsmith build rebuilds only targets whose inputs
or recipe spec have changed since the last
successful build. Authors declare inputs per
directive; mdsmith computes a content hash and
stores it per output. The next run skips
up-to-date targets.
Plan 102 adds mdsmith build that always
rebuilds every target. For doc trees with many
recipes (every screenshot, every diagram, every
generated table), this wastes time and produces
noisy git diffs from regenerated artifacts whose
inputs did not change.
The article behind this PR (bgslabs.org/blog/why-are-we-using-markdown) calls out the missing piece directly. It frames the build-system semantic as "sources update → result must update". Plans 100–102 give the shape. They lack the behavior.
Git checkouts touch file mtimes. CI runners often
do not preserve mtime across cache restore. Two
checkouts of the same commit on different
machines disagree on mtime but agree on content.
Hash gives reproducible "did this actually
change" answers; mtime does not. Cost is one
read per declared input per mdsmith build
invocation; for typical doc trees (hundreds of
small files), negligible.
New optional parameter on <?build?>:
<?build
recipe: pandoc
inputs:
- chapters/intro.md
- chapters/01-*.md
output: book.html
?>
[book.html](book.html)
<?/build?>
inputs is a list of paths or globs, each
resolved relative to the Markdown file
containing the <?build?> directive (matching
how plan 101 resolves output). Globs are
evaluated at build time, expanded to the
matching set, sorted, and folded into the
hash. An inputs entry that resolves to zero
files is a build error (likely a typo).
Recipes may declare implicit inputs in
build.recipes.NAME.default-inputs. The token
{param} expands to the directive's value for
that param. Resolution is the same as for
directive inputs: relative to the Markdown
file containing the <?build?> directive.
Built-in defaults:
| Recipe | default-inputs |
|---|---|
vhs |
[{input}] (the .tape file) |
screenshot |
(none) |
For screenshot, the source is a live URL —
mdsmith cannot tell whether the page changed
without fetching it. The author opts in by
adding inputs: (e.g. when the page is built
from local files served by a static dev server).
With no inputs, screenshot targets are
considered always-stale and always rebuild.
For each target, in order:
- If
outputdoes not exist → stale. - Resolve
inputs(directiveinputs∪ recipedefault-inputs); if any non-glob entry is missing → build error. - Compute
hash = sha256(spec ‖ each input content)wherespecis the canonical serialization of(recipe, command-template, sorted directive params, sorted resolved input paths). - Look up
outputin the cache. If absent or stored hash differs → stale. - Otherwise → up-to-date; skip the recipe.
Stored at .mdsmith/build-cache.json. JSON,
human-inspectable:
{
"version": 1,
"entries": {
"book.html": {
"hash": "sha256:...",
"built-at": "2026-04-27T12:00:00Z",
"inputs": [
"chapters/intro.md",
"chapters/01-prologue.md"
]
}
}
}output paths are stored relative to the project
root. inputs are stored post-glob-expansion and
sorted, so a reviewer can see exactly what the
build considered.
The directory .mdsmith/ is added to a
recommended .gitignore snippet in the user
guide; the file is per-clone state, like
node_modules/.
| Flag | Behavior |
|---|---|
| (none) | Default: rebuild only stale targets; refresh cache entries for rebuilt targets |
--force |
Rebuild every target regardless of cache; refresh all cache entries |
--check-stale |
Print every stale target, exit non-zero if any are stale, do not rebuild (CI gate use) |
--no-cache |
Treat all targets as stale, do not read or write the cache (debugging) |
--check-stale makes "every artifact is up to
date with its source" a CI signal a reviewer can
trust, parallel to "every generated section is
fresh" today.
The cache key includes the recipe command
template, so changing a recipe in .mdsmith.yml
invalidates every target using it. The
build-cache.json version field exists so a
future mdsmith release can rev it (e.g. switching
hash algorithm) and force a single rebuild
without crashing on stale schemas.
- Plan 100: extends
RecipeCfgwithdefault-inputs. MDS040 validates each entry is a{param}token referring to a declared param, or a literal path with no... - Plan 101: extends MDS039 to accept
the optional
inputsdirective parameter. Validates each entry is a relative path (no absolute paths, no..). - Plan 102:
mdsmith buildreads and writes the cache file, runs the staleness check before invoking each Builder.
- Reverse dependency tracking ("rebuild B when A
rebuilds because B includes A"). One target →
one build; declared
inputsare the only edge. - Parallel builds. Sequential is fine for documentation-scale work; revisit only if real use proves it slow.
- Watch mode (
mdsmith build --watch). Use the shell or an editor task runner for now. - Cross-machine cache sharing (Bazel-style
remote cache). The
build-cache.jsonis intentionally local.
- Extend
RecipeCfgininternal/config/withdefault-inputs. Validate each entry is either{param}(param must be declared inparams.requiredorparams.optional) or a relative path with no... Add coverage in MDS040. - Extend MDS039 to accept the optional
inputsdirective parameter (list of strings). Validate each entry is a relative path with no... Update fixtures. - Implement
internal/build/cache.go: load/save.mdsmith/build-cache.json, atomic write via temp+rename. - Implement
internal/build/staleness.go: resolve directiveinputs∪ recipedefault-inputs, expand globs, computesha256(spec ‖ inputs), compare against cached hash, return stale/fresh. - Wire staleness into
mdsmith build: default skips fresh, refreshes cache for rebuilt targets, atomic cache write at the end of the run. - Add flags
--force,--check-stale,--no-cachewith the behavior above. - Built-in
vhsdeclaresdefault-inputs: [{input}]. Built-inscreenshotdeclares none (always-stale by default). - Integration tests:
- A
cp-based recipe withinputs: [src.txt]skips on second run; rebuilds whensrc.txtcontent changes. - Touching
src.txtmtime without changing content does not trigger a rebuild. - Changing the recipe
commandin.mdsmith.ymltriggers a rebuild for all targets using that recipe. --forcerebuilds even when fresh.--check-staleexits non-zero with stale output and zero with fresh output.
- Document the staleness model and cache file
in
docs/guides/directives/build.md. Add the.mdsmith/ignore snippet to the README and to a futuremdsmith init.
- A second
mdsmith buildinvocation with no source changes runs zero recipes - Changing the content of a declared input triggers a rebuild of just that target
- Touching mtime without changing content does not trigger a rebuild
- Changing a recipe
commandin.mdsmith.ymlinvalidates every target using that recipe - An
inputs:glob that matches zero files is a build error -
screenshottargets withoutinputs:are always rebuilt -
vhstargets default toinputs: [{input}]and skip when the.tapefile is unchanged -
mdsmith build --forcerebuilds every target regardless of cache -
mdsmith build --check-staleprints stale targets and exits non-zero without rebuilding -
mdsmith build --no-cacherebuilds everything and writes nothing to.mdsmith/build-cache.json -
.mdsmith/build-cache.jsonis JSON with aversionfield and per-output entries includinghash,built-at, and resolvedinputs - Cache writes are atomic (temp+rename); a mid-build crash leaves the previous cache readable
- All tests pass:
go test ./... -
go tool golangci-lint runreports no issues