| id | 2606101546 | |
|---|---|---|
| title | Builder execution wired into `mdsmith fix` | |
| status | ✅ | |
| summary | Run `<?build?>` directives during `mdsmith fix`. A `Builder` interface dispatches the user-declared recipe via `os/exec` and writes every declared output atomically. No built-in recipes; no `mdsmith build` subcommand. New flags: `--no-build`, `--build-only`, `--build-recipe`, `--build-dry-run`, `--build-timeout`. Removes `build.base-url`. | |
| model | opus | |
| depends-on |
|
mdsmith fix runs the build pass after the
existing lint-fix pass. Each <?build?>
directive dispatches its user-declared recipe
via os/exec and writes the declared
artifacts atomically. A per-target
OK | FAIL summary is printed. mdsmith check stays lint-only.
Builds on plan 102 (directive params) and plan 100 (config). Plan 103 layers staleness on top. Plan 104 adds hooks. Plan 2606101547 covers stdout/stderr UX and parallel execution.
The original 102 plan included a separate
mdsmith build subcommand and two built-in
recipe drivers (screenshot via chromedp,
vhs). All are removed: builds run inside
mdsmith fix; every recipe lives in
build.recipes. build.base-url is also
removed (its only consumer was the deleted
screenshot driver).
// internal/build/builder.go
type Builder interface {
Build(ctx context.Context, target Target) error
}
type Target struct {
Recipe string
Params map[string]string
Root string // absolute project root
Inputs []string // project-root-relative, slash-normalized
Outputs []string // project-root-relative, slash-normalized
}The custom-recipe builder is the sole impl.
One instance per recipe in build.recipes.
Paths are stored relative so plan 103's
ActionID is stable across clone locations;
absolute paths are recomposed via
filepath.Join(target.Root, p) at exec time.
Tokenization uses strings.Fields on
command at config load. Substitution of
{param}, {inputs}, {outputs} happens
after tokenization, so a param value
containing whitespace stays a single argv
entry. A value like foo; rm -rf / is
passed literally as one argument.
The basic contract (plan 2606101548 hardens it):
- mdsmith creates a per-target temp dir.
- The recipe is invoked with the staging
paths substituted for
{outputs}; named params pass through verbatim (plan 102). - On success, mdsmith renames every temp file to its final location.
- On failure, the temp dir is removed; no declared output is touched.
Plan 2606101548 adds the security hardening. That covers the trust gate, hermetic env, hardened staging dir, output post- conditions, and process-group kill on timeout.
mdsmith fix [paths] [flags] gains a build
pass after the existing lint-fix pass:
- Lint-fix pass runs (rules apply fixes,
generated sections — including each
<?build?>body — get re-rendered). - Build pass runs:
- Collect all
<?build?>directives in file order, reusing the lint pass's directive walk — no second workspace walk. - For each, dispatch to the recipe's
Builder.Build. - Write outputs atomically.
- Append a
OK | FAILline per target.
- Final exit code is non-zero on any rule error or any build failure.
The build pass runs after lint-fix so that a
freshly-edited outputs: list is built with
its new value, not the old.
The build pass lives in cmd/mdsmith and
internal/build/. It is not part of the
public pkg/mdsmith Session API and is
excluded from the WASM bindings (plan 215):
recipes exec processes, which the WASM and
LSP in-memory fix paths must never do. The
in-process fix API (fix.Source) has no
build stage, which exempts the LSP and the
merge driver by construction. The
pre-merge-commit hook script still runs the
CLI, so task 4 wires --no-build into its
generator (internal/githooks). A merge
never executes recipes.
| Flag | Behavior |
|---|---|
| (none) | Lint-fix pass + build pass |
--no-build |
Lint-fix pass only |
--build-only |
Build pass only |
--build-recipe NAME |
Only build directives with recipe: NAME |
--build-dry-run |
Enumerate targets (and hooks once plan 104 lands); run nothing |
--build-timeout DUR |
Per-recipe timeout (default 30s) |
--no-build and --build-only are mutually
exclusive. The --build-* prefix groups
build flags and avoids collision with future
lint-fix flags.
The same pattern governs later build
flags. Inspect modes run no recipe:
--build-dry-run, later
--build-check-stale and
--build-explain. They exclude each other
and the execution-altering --build-force,
--build-no-cache, and --build-verify;
combining them is a usage error. Tuning
flags like
--build-timeout or --build-jobs
combine freely with a run.
- Define
BuilderandTargetin a new packageinternal/build/. Implement the custom-recipe builder. Tokenize viastrings.Fields; substitute{param},{inputs},{outputs}after. Resolveinputs:globs with the doublestar matcher, re-check every resolved path against the project root through plan 102's shared validator, and enforce the 10 000-match glob cap there. - Implement basic multi-output atomic write: per-target temp dir, post-recipe rename, full rollback on failure. Plan 2606101548 adds the hardening.
- Remove
build.base-urlfrominternal/config/build.goand its tests. Add an explicit top-level-key scan that errors ifbuild.base-urlis still present (non-strict YAML would otherwise ignore it silently). Drop docs that reference it. - Wire the build pass into
mdsmith fixincmd/mdsmith/. Add the five flags above. Print per-target summary; non-zero exit on failure. Keep the pass out ofpkg/mdsmithandfix.Source. Pass--no-buildin theinternal/githooksscript (regenerate its golden file). - Integration tests:
cp-based single-output recipe runs viafix.tee-based multi-output recipe writes both files atomically.- Failing recipe leaves no partial outputs.
- All five
--build-*flags work as documented. mdsmith checkruns no recipe.
- Update
docs/guides/directives/build.md: document the build pass, the newfixflags, the per-target summary, and{outputs}/{inputs}argv expansion. Remove references tomdsmith build, built-in recipes, andbase-url. Add a markdown-as-data example: a recipe runsmdsmith extracton an input file and pipes the JSON into a chart tool. Note indocs/reference/telemetry.mdthat recipes are user code; mdsmith itself still makes no network calls. - Update
demo.tapeto use acp-based custom recipe so the demo showsfixrunning a build.
-
mdsmith fixruns the build pass after lint-fix;mdsmith checkruns no recipe - All five
--build-*flags work as documented;--no-buildand--build-onlyare mutually exclusive - Single-output and multi-output
cp/teerecipes write every declared output via one invocation - A failing recipe leaves no partial output; pre-existing outputs survive
- Custom
commandis dispatched viaos/execwith explicit argv; no shell is invoked -
{outputs}and{inputs}each expand to one argv per resolved entry - A resolved input escaping the project root, or one glob matching more than 10 000 files, is a build error
- Tokenization uses
strings.Fields; param values containing whitespace pass as one argv entry -
mdsmith fixexits non-zero on any recipe failure with per-targetOK | FAILsummary -
build.base-urlis removed. Config loading uses non-strict YAML (yamlutil.UnmarshalSafe), so the struct field's absence alone is silent. An explicit top-level-key scan detects a lingeringbuild.base-urland errors with "build.base-url was removed in plan 2606101546; delete it" - No built-in recipes ship; an unknown
recipe:is a lint error (MDS039) -
pkg/mdsmith, the WASM bindings, LSP fix, the merge driver, and the pre-merge-commit hook never run the build pass - All tests pass:
go test ./... -
go tool golangci-lint runreports no issues