| id | 104 | ||
|---|---|---|---|
| title | Build lifecycle hooks (before/after) | ||
| status | ✅ | ||
| summary | Add `build.hooks.before` and `build.hooks.after` config blocks: argv-tokenized commands run once per `mdsmith fix` build pass, around the recipe pass. Lets users start a dev server before screenshots and stop it after, or warm a cache before generating diagrams. Same `os/exec` argv path and MDS040 lint as recipes — no shell. | ||
| model | sonnet | ||
| depends-on |
|
The mdsmith fix build pass (plan 2606101546) runs
declared before commands once before any
recipe and declared after commands once
after the recipe pass. Failure semantics are
explicit and CI-friendly.
Plan 2606101546 ships the build pass inside mdsmith fix; plan 103 adds staleness. Neither
provides setup/teardown lifecycle. The
motivating example is a user-declared
screenshot recipe that needs a dev server
running. Hooks fold "start server, run
recipes, stop server" into one command.
Extend the build: block from plan 100:
build:
hooks:
before:
- command: "make dev-server-start"
- command: "scripts/wait-for-port {port}"
params:
port: "3000"
after:
- command: "make dev-server-stop"Each hook entry shape:
| Field | Required | Description |
|---|---|---|
command |
yes | Argv template; same {param} rules as recipes |
params |
no | Map of param name → literal value |
name |
no | Display name (defaults to first token) |
Hooks have no directive surface. They are a
config-level construct, run once per fix
build pass, not per directive.
1. Lint-fix pass (existing fix behavior)
2. before[0], before[1], … (in order, plan 104)
3. recipe pass (plan 2606101546)
4. after[0], after[1], … (in order, plan 104)
Hooks are part of the build pass.
--no-build (plan 2606101546) skips the build pass
and therefore both hook lists. --build-only
(plan 2606101546) skips step 1 (lint-fix) but still
runs steps 2–4 in order — hooks bracket the
recipe pass either way.
beforefails (non-zero exit): print stderr and exit code, run no recipes, run noafterhooks. The lint-fix pass already ran; its results stand.- Recipe fails: finish the recipe pass
per plan 2606101546's
OK | FAILsummary (plan 103 addsSKIPonce staleness lands), then runafterhooks. Final exit non-zero. afterfails: print stderr and exit code, continue running remainingafterhooks. Final exit non-zero.- Multiple failures — final exit code
priority: lint-fix errors →
before-fail→ recipe-fail →after-fail→ 0.
The asymmetry is intentional. A failed
before means setup is incomplete. Recipes
would produce garbage, so abort. A failed
after means teardown is broken. Artifacts
are written; report and exit non-zero.
Same as recipes (plan 100):
- Split
commandon whitespace. - Expand
{param}tokens using the hook'sparamsmap. - Pass the resulting argv to
os/exec.Cmd. No shell.
A {param} token with no matching params
entry is a config error caught by MDS040. The
reserved-name list (inputs, outputs, plan
102) applies: a hook's params may not
declare them, and a hook's command may not
reference {inputs} or {outputs} (hooks
have no directive context).
Extend MDS040 (plan 100) to lint hook
command strings with the same rules as
recipes:
- Non-empty.
- First token is not a shell interpreter.
- No shell operators in static parts.
- No fused
{param}placeholders. - No
..in the executable token. - Reserved names (
inputs,outputs) absent.
A hook params entry must be referenced by
at least one {param} token in its
command; unused params are a warning.
Hook params values are pure config strings.
MDS040's path-shape checks apply only to the
executable token, not to substituted values,
so params: { target: "../../etc/shadow" }
with command: "cat {target}" slips through
without further checks.
MDS040 enforces a baseline on every hook param value: no NUL byte, no newline or carriage return, no leading or trailing whitespace, length ≤ 4 KB. Operators who need stricter checks (port range, URL shape, project-relative paths) wrap the binary in a script that does its own validation.
The baseline is intentionally narrow.
Per-kind value schemas (kind: path | port | url) are a future extension; for now
the bar is "no control characters in argv"
and operators keep flexibility.
The build pass refuses to run if MDS040
emits any error against build.hooks or
build.recipes. A lint-clean config is a
precondition for executing any user-declared
binary. --no-build still works for
debugging without the gate.
| Flag | Behavior |
|---|---|
--no-build |
(plan 2606101546) Skip the entire build pass — including hooks |
--build-no-hooks |
Run the build pass but skip both before and after hooks |
--build-skip-hooks-when-fresh |
Skip both lists when no target is stale; run them otherwise |
--build-recipe NAME (plan 2606101546) does not
filter hooks — they are global.
--build-dry-run (plan 2606101546) lists hooks
alongside recipes; nothing executes.
--build-check-stale (plan 103) also runs
no hooks.
If every target is up-to-date and would be
skipped, before and after still run by
default — they may have effects beyond the
recipes (publishing, notifications). To skip
them when nothing would build, use
--build-skip-hooks-when-fresh. The naming is
intentional: the default favors
predictability.
Per-recipe hooks. Separate hook timeouts.
Conditional if: clauses. Per-kind param
schemas. Background hooks — before
returns synchronously; spawn-then-kill
via PID file.
- Extend
BuildConfigininternal/config/withHooks HooksCfg. DefineHooksCfgwithBefore []HookCfgandAfter []HookCfg. Validate eachHookCfglikeRecipeCfg.command, including the reserved-name list from plan 102. - Extend MDS040 to lint hook
commandstrings using the existing rule set. Add fixtures covering shell interpreter, shell operator, fused-placeholder, and reserved-name cases. - Add
internal/build/hooks.go: arunHookshelper that takes a list ofHookCfg, tokenizes and expands each, dispatches viaos/exec, returns the first failure. - Wire
runHooksinto themdsmith fixbuild pass (plan 2606101546): runbeforeimmediately before the recipe pass; on failure exit immediately with the hook's exit code, run no recipes, run noafterhooks. After the recipe pass, runafterregardless of recipe results. - Add
--build-no-hooksand--build-skip-hooks-when-freshflags. Update--build-dry-runto list hooks. - Integration tests:
- Test harness starts an
httptest.Serverin setup. Abeforehook touches a sentinel; a user-declared screenshot recipe (real headless-browser binary if available, else acp-based stub) captures the server; anafterhook touches a second sentinel. Assert both sentinels exist and the artifact was written. beforehook returning non-zero aborts the build pass with no recipes and noafterhooks; lint-fix results stand.afterhook returning non-zero is reported but exits with the recipe-pass exit code priority.--build-no-hooksskips both lists.--build-skip-hooks-when-freshwith all-fresh skips both; with any stale runs both.mdsmith fix --no-buildskips hooks entirely.
- Document the hook lifecycle, failure
semantics, and flag matrix in
docs/guides/directives/build.md. Cover the dev-server-around-screenshots example end-to-end with a user-declared recipe.
-
beforehooks run in declaration order, once perfixbuild pass, before any recipe -
afterhooks run in declaration order, once per build pass, after the recipe pass - A failing
beforeaborts with no recipes and noafterhooks; lint-fix pass results are preserved - A failing
afteris reported but does not prevent laterafterhooks from running - Final exit code prioritises lint-fix
errors over
before-failoverrecipe-failoverafter-fail - Hook
commandis split into argv and dispatched viaos/exec; no shell interpreter is invoked - MDS040 flags hook commands that start
with
bash/sh, contain shell operators, contain fused{param}placeholders, or reference reserved names (inputs,outputs) -
{param}tokens in a hookcommandexpand from the hook'sparamsmap; an unmatched token is a config error -
mdsmith fix --build-no-hooksskips both lists and runs only the recipe pass -
mdsmith fix --no-buildskips both hooks and recipes (no build pass) -
mdsmith fix --build-dry-runlists each hook alongside the recipes it bookends -
mdsmith fix --build-skip-hooks-when-freshskips both lists when no target is stale and runs both when any target is stale - A config without
build.hooksparses cleanly and runsmdsmith fixwithout hook overhead - MDS040 rejects hook
paramsvalues with NUL, newline, leading/trailing whitespace, or > 4 KB - Build pass refuses to run when MDS040
reports any error against
build.hooksorbuild.recipes - All tests pass:
go test ./... -
go tool golangci-lint runreports no issues