Skip to content

Commit c013640

Browse files
author
merge-queue-bot
committed
Merge PR #223: Implement MDS039 build directive rule
2 parents f68fbc0 + d35bb4e commit c013640

24 files changed

Lines changed: 1523 additions & 189 deletions

.github/copilot-instructions.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ row: "- [{summary}](../{filename})"
3434
- [Build commands, project layout, code style, test fixtures, coverage gate, and merge conflicts.](../docs/development/index.md)
3535
- [Label-driven merge queue workflow using jeduden/merge-queue-action.](../docs/development/merge-queue.md)
3636
- [Rebase, CI monitoring, and review comment resolution.](../docs/development/pr-fixup-workflow.md)
37+
- [How to use the build directive to declare artifact outputs, keep generated bodies in sync, and configure user-declared recipes.](../docs/guides/directives/build.md)
3738
- [How to use schemas, require, and allow-empty-section to validate headings, front matter, and filenames.](../docs/guides/directives/enforcing-structure.md)
3839
- [How to use catalog and include directives to generate and embed content in Markdown files.](../docs/guides/directives/generating-content.md)
3940
- [Key differences between Hugo templates and mdsmith directives for users familiar with Hugo.](../docs/guides/directives/hugo-migration.md)

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ row: "- [{summary}]({filename})"
4040
- [Build commands, project layout, code style, test fixtures, coverage gate, and merge conflicts.](docs/development/index.md)
4141
- [Label-driven merge queue workflow using jeduden/merge-queue-action.](docs/development/merge-queue.md)
4242
- [Rebase, CI monitoring, and review comment resolution.](docs/development/pr-fixup-workflow.md)
43+
- [How to use the build directive to declare artifact outputs, keep generated bodies in sync, and configure user-declared recipes.](docs/guides/directives/build.md)
4344
- [How to use schemas, require, and allow-empty-section to validate headings, front matter, and filenames.](docs/guides/directives/enforcing-structure.md)
4445
- [How to use catalog and include directives to generate and embed content in Markdown files.](docs/guides/directives/generating-content.md)
4546
- [Key differences between Hugo templates and mdsmith directives for users familiar with Hugo.](docs/guides/directives/hugo-migration.md)

CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ row: "- [{summary}]({filename})"
2626
- [Build commands, project layout, code style, test fixtures, coverage gate, and merge conflicts.](docs/development/index.md)
2727
- [Label-driven merge queue workflow using jeduden/merge-queue-action.](docs/development/merge-queue.md)
2828
- [Rebase, CI monitoring, and review comment resolution.](docs/development/pr-fixup-workflow.md)
29+
- [How to use the build directive to declare artifact outputs, keep generated bodies in sync, and configure user-declared recipes.](docs/guides/directives/build.md)
2930
- [How to use schemas, require, and allow-empty-section to validate headings, front matter, and filenames.](docs/guides/directives/enforcing-structure.md)
3031
- [How to use catalog and include directives to generate and embed content in Markdown files.](docs/guides/directives/generating-content.md)
3132
- [Key differences between Hugo templates and mdsmith directives for users familiar with Hugo.](docs/guides/directives/hugo-migration.md)

PLAN.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ footer: |
1717
| ID | Status | Model | Title |
1818
|-----|--------|--------|------------------------------------------------------------------------------------------------------|
1919
| 100 || sonnet | [build config block and MDS040 recipe-safety rule](plan/100_build-config-and-mds040.md) |
20-
| 101 | 🔲 | sonnet | [build directive and MDS039 lint rule](plan/101_build-directive-mds039.md) |
20+
| 101 | | sonnet | [build directive and MDS039 lint rule](plan/101_build-directive-mds039.md) |
2121
| 102 | 🔲 | opus | [Builder interface and mdsmith build subcommand](plan/102_build-subcommand.md) |
2222
| 103 | 🔲 | opus | [Build target staleness and dependency tracking](plan/103_build-staleness-and-deps.md) |
2323
| 104 | 🔲 | sonnet | [Build lifecycle hooks (before/after)](plan/104_build-lifecycle-hooks.md) |

cmd/mdsmith/main.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import (
2828
_ "github.com/jeduden/mdsmith/internal/rules/blanklinearoundfencedcode"
2929
_ "github.com/jeduden/mdsmith/internal/rules/blanklinearoundheadings"
3030
_ "github.com/jeduden/mdsmith/internal/rules/blanklinearoundlists"
31+
_ "github.com/jeduden/mdsmith/internal/rules/build"
3132
_ "github.com/jeduden/mdsmith/internal/rules/catalog"
3233
_ "github.com/jeduden/mdsmith/internal/rules/concisenessscoring"
3334
_ "github.com/jeduden/mdsmith/internal/rules/crossfilereferenceintegrity"

docs/guides/directives/build.md

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
---
2+
title: Build directive
3+
summary: >-
4+
How to use the build directive to declare artifact outputs, keep
5+
generated bodies in sync, and configure user-declared recipes.
6+
---
7+
# Build directive
8+
9+
The `<?build?>` directive declares a build artifact — a file
10+
produced by a recipe configured in `build.recipes`. `mdsmith fix`
11+
renders the section body from the recipe's `body-template` and
12+
keeps it up to date. No external tool runs at lint time.
13+
14+
## Syntax
15+
16+
```text
17+
<?build
18+
recipe: RECIPE-NAME
19+
output: path/to/artifact.ext
20+
[recipe-specific params]
21+
?>
22+
RENDERED BODY
23+
<?/build?>
24+
```
25+
26+
The directive uses the same block form as `<?catalog?>` and
27+
`<?include?>`. Inline form is not supported.
28+
29+
### Common parameters
30+
31+
| Name | Required | Description |
32+
|----------|----------|---------------------------------------------------------------|
33+
| `recipe` | yes | Recipe name declared in `build.recipes` |
34+
| `output` | yes | Relative artifact path; no `..` components; no absolute paths |
35+
36+
`output` accepts any file extension; the rule applies no extension
37+
filter.
38+
39+
## Declaring recipes
40+
41+
All recipes must be declared in `build.recipes` in `.mdsmith.yml`.
42+
A `<?build?>` directive can only reference recipes declared there;
43+
it cannot introduce a new recipe inline.
44+
45+
```yaml
46+
build:
47+
recipes:
48+
render:
49+
command: "myrenderer {source} -o {output}"
50+
body-template: "![{alt}]({output})"
51+
params:
52+
required: [source]
53+
optional: [title, output]
54+
vhs:
55+
command: "vhs {input}"
56+
body-template: "![{alt}]({output})"
57+
params:
58+
required: [input]
59+
```
60+
61+
Then in a Markdown file:
62+
63+
```text
64+
<?build
65+
recipe: render
66+
source: diagram.svg
67+
output: docs/diagram.png
68+
?>
69+
![render output: docs/diagram.png](docs/diagram.png)
70+
<?/build?>
71+
```
72+
73+
## Generated body
74+
75+
`mdsmith fix` renders the section body from the recipe's
76+
`body-template`. Two placeholders are available:
77+
78+
| Placeholder | Value |
79+
|-------------|-----------------------------------------|
80+
| `{output}` | The `output` param value |
81+
| `{alt}` | `"{recipe} output: {output}"` (default) |
82+
83+
When `body-template` is omitted from the recipe declaration, the
84+
default `[{output}]({output})` is used.
85+
86+
## Rule MDS039
87+
88+
MDS039 validates `<?build?>` directives and reports:
89+
90+
- **Error** when `recipe` is missing or not declared in `build.recipes`
91+
- **Error** when `output` is missing, is an absolute path, or contains `..` components
92+
- **Error** when a required param for the recipe is absent
93+
- **Warning** when a param is not in the recipe's `required` or
94+
`optional` lists
95+
- **Error** (`generated section is out of date`) when the body
96+
diverges from the rendered `body-template`
97+
98+
Run `mdsmith fix <file>` to regenerate stale bodies.
99+
100+
## Interaction with other rules
101+
102+
- **MDS027**: a missing artifact file fires MDS027 independently;
103+
MDS039 does not duplicate it.
104+
- **MDS040**: validates `build.recipes` command safety at lint time;
105+
MDS039 validates `<?build?>` directive usage in Markdown files.
106+
- **merge-driver**: regenerates `<?build?>` bodies on conflict
107+
via `gensection.Engine`; artifact bytes are not regenerated.

docs/guides/index.md

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,12 @@ header: |
1616
|-------|-------------|
1717
row: "| [{title}]({filename}) | {summary} |"
1818
?>
19-
| Guide | Description |
20-
|-------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------|
21-
| [Choosing Readability, Conciseness, and Token Budget Metrics](metrics-tradeoffs.md) | Trade-offs and threshold guidance for readability, structure, length, and token budgets. |
22-
| [Coming from Hugo](directives/hugo-migration.md) | Key differences between Hugo templates and mdsmith directives for users familiar with Hugo. |
23-
| [Enforcing Document Structure with Schemas](directives/enforcing-structure.md) | How to use schemas, require, and allow-empty-section to validate headings, front matter, and filenames. |
24-
| [File Kinds](file-kinds.md) | How to declare file kinds, assign files to them, and read the merged rule config that results. |
25-
| [Generating Content with Directives](directives/generating-content.md) | How to use catalog and include directives to generate and embed content in Markdown files. |
19+
| Guide | Description |
20+
|-------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------|
21+
| [Build directive](directives/build.md) | How to use the build directive to declare artifact outputs, keep generated bodies in sync, and configure user-declared recipes. |
22+
| [Choosing Readability, Conciseness, and Token Budget Metrics](metrics-tradeoffs.md) | Trade-offs and threshold guidance for readability, structure, length, and token budgets. |
23+
| [Coming from Hugo](directives/hugo-migration.md) | Key differences between Hugo templates and mdsmith directives for users familiar with Hugo. |
24+
| [Enforcing Document Structure with Schemas](directives/enforcing-structure.md) | How to use schemas, require, and allow-empty-section to validate headings, front matter, and filenames. |
25+
| [File Kinds](file-kinds.md) | How to declare file kinds, assign files to them, and read the merged rule config that results. |
26+
| [Generating Content with Directives](directives/generating-content.md) | How to use catalog and include directives to generate and embed content in Markdown files. |
2627
<?/catalog?>

internal/config/build.go

Lines changed: 26 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -121,31 +121,37 @@ func validateCommandPlaceholders(recipeName, command string, allowed map[string]
121121
return nil
122122
}
123123

124-
// InjectBuildConfig copies cfg.Build.Recipes into the recipe-safety
125-
// rule settings, alongside the config file path. It is called after
126-
// config loading in main so the rule receives its inputs through the
127-
// normal ApplySettings path. cfgPath is the path to the loaded
128-
// .mdsmith.yml; it is set in the config-path setting so MDS040 can
129-
// report diagnostics against the
130-
// right file.
124+
// InjectBuildConfig copies cfg.Build.Recipes into the recipe-safety and
125+
// build rule settings. It is called after config loading in main so rules
126+
// receive their inputs through the normal ApplySettings path. cfgPath is
127+
// the path to the loaded .mdsmith.yml; it is set in the config-path
128+
// setting so MDS040 can report diagnostics against the right file.
131129
func InjectBuildConfig(cfg *Config, cfgPath string) {
132-
if cfg == nil || len(cfg.Build.Recipes) == 0 {
133-
return
134-
}
135-
const name = "recipe-safety"
136-
rc, ok := cfg.Rules[name]
137-
if !ok || !rc.Enabled {
130+
if cfg == nil {
138131
return
139132
}
140-
if rc.Settings == nil {
141-
rc.Settings = make(map[string]any)
133+
recipes := serializeRecipes(cfg.Build.Recipes)
134+
135+
// Inject into recipe-safety (MDS040) with config-path.
136+
if rc, ok := cfg.Rules["recipe-safety"]; ok && rc.Enabled {
137+
if rc.Settings == nil {
138+
rc.Settings = make(map[string]any)
139+
}
140+
rc.Settings["recipes"] = recipes
141+
if cfgPath != "" {
142+
rc.Settings["config-path"] = cfgPath
143+
}
144+
cfg.Rules["recipe-safety"] = rc
142145
}
143-
// Always overwrite: recipes must come from build:, not user rule settings.
144-
rc.Settings["recipes"] = serializeRecipes(cfg.Build.Recipes)
145-
if cfgPath != "" {
146-
rc.Settings["config-path"] = cfgPath
146+
147+
// Inject into build directive (MDS039).
148+
if rc, ok := cfg.Rules["build"]; ok && rc.Enabled {
149+
if rc.Settings == nil {
150+
rc.Settings = make(map[string]any)
151+
}
152+
rc.Settings["recipes"] = recipes
153+
cfg.Rules["build"] = rc
147154
}
148-
cfg.Rules[name] = rc
149155
}
150156

151157
// serializeRecipes converts RecipeCfg map to map[string]any for transport

internal/config/build_test.go

Lines changed: 92 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -173,15 +173,40 @@ func TestInjectBuildConfig_Nil(t *testing.T) {
173173
InjectBuildConfig(nil, "")
174174
}
175175

176+
func TestInjectBuildConfig_EmptyRecipes_ClearsExistingSettings(t *testing.T) {
177+
// Even with no build.recipes, InjectBuildConfig must overwrite any
178+
// user-supplied recipes setting so rules cannot receive recipes via
179+
// rule settings alone.
180+
cfg := &Config{
181+
Build: BuildConfig{}, // no recipes
182+
Rules: map[string]RuleCfg{
183+
"build": {
184+
Enabled: true,
185+
Settings: map[string]any{"recipes": map[string]any{"sneaky": map[string]any{}}},
186+
},
187+
},
188+
}
189+
InjectBuildConfig(cfg, "")
190+
rc := cfg.Rules["build"]
191+
require.NotNil(t, rc.Settings)
192+
recipes, ok := rc.Settings["recipes"].(map[string]any)
193+
require.True(t, ok)
194+
assert.Empty(t, recipes, "recipes must be cleared when build.recipes is empty")
195+
}
196+
176197
func TestInjectBuildConfig_NoRecipes(t *testing.T) {
177198
cfg := &Config{
178199
Rules: map[string]RuleCfg{
179200
"recipe-safety": {Enabled: true},
180201
},
181202
}
182203
InjectBuildConfig(cfg, ".mdsmith.yml")
183-
// Settings must remain nil/empty — nothing to inject.
184-
assert.Nil(t, cfg.Rules["recipe-safety"].Settings)
204+
// An empty recipes map is still injected to overwrite any user-supplied settings.
205+
rc := cfg.Rules["recipe-safety"]
206+
require.NotNil(t, rc.Settings)
207+
recipes, ok := rc.Settings["recipes"].(map[string]any)
208+
require.True(t, ok)
209+
assert.Empty(t, recipes)
185210
}
186211

187212
func TestInjectBuildConfig_RuleDisabled(t *testing.T) {
@@ -276,6 +301,71 @@ func TestInjectBuildConfig_OverwritesExistingSettings(t *testing.T) {
276301
assert.True(t, isMap, "recipes should be overwritten with a map")
277302
}
278303

304+
func TestInjectBuildConfig_BuildRule(t *testing.T) {
305+
cfg := &Config{
306+
Build: BuildConfig{
307+
Recipes: map[string]RecipeCfg{
308+
"render": {
309+
Command: "myrenderer {source} -o {output}",
310+
BodyTemplate: "![{alt}]({output})",
311+
Params: ParamCfg{
312+
Required: []string{"source"},
313+
Optional: []string{"output"},
314+
},
315+
},
316+
},
317+
},
318+
Rules: map[string]RuleCfg{
319+
"build": {Enabled: true},
320+
},
321+
}
322+
InjectBuildConfig(cfg, ".mdsmith.yml")
323+
324+
rc := cfg.Rules["build"]
325+
require.NotNil(t, rc.Settings)
326+
recipesAny, ok := rc.Settings["recipes"]
327+
require.True(t, ok, "recipes key must be present in build rule settings")
328+
recipes, ok := recipesAny.(map[string]any)
329+
require.True(t, ok)
330+
assert.Contains(t, recipes, "render")
331+
// config-path must NOT be injected into the build rule (only recipe-safety gets it)
332+
_, hasPath := rc.Settings["config-path"]
333+
assert.False(t, hasPath)
334+
}
335+
336+
func TestInjectBuildConfig_BuildRule_NilSettings(t *testing.T) {
337+
cfg := &Config{
338+
Build: BuildConfig{
339+
Recipes: map[string]RecipeCfg{
340+
"x": {Command: "tool"},
341+
},
342+
},
343+
Rules: map[string]RuleCfg{
344+
"build": {Enabled: true, Settings: nil},
345+
},
346+
}
347+
InjectBuildConfig(cfg, "cfg.yml")
348+
rc := cfg.Rules["build"]
349+
require.NotNil(t, rc.Settings)
350+
_, ok := rc.Settings["recipes"]
351+
assert.True(t, ok)
352+
}
353+
354+
func TestInjectBuildConfig_BuildRule_Disabled(t *testing.T) {
355+
cfg := &Config{
356+
Build: BuildConfig{
357+
Recipes: map[string]RecipeCfg{
358+
"x": {Command: "tool"},
359+
},
360+
},
361+
Rules: map[string]RuleCfg{
362+
"build": {Enabled: false},
363+
},
364+
}
365+
InjectBuildConfig(cfg, "cfg.yml")
366+
assert.Nil(t, cfg.Rules["build"].Settings)
367+
}
368+
279369
func TestInjectBuildConfig_EmptyCfgPath_NoPathInjected(t *testing.T) {
280370
cfg := &Config{
281371
Build: BuildConfig{

internal/integration/rules_test.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import (
2020
_ "github.com/jeduden/mdsmith/internal/rules/blanklinearoundfencedcode"
2121
_ "github.com/jeduden/mdsmith/internal/rules/blanklinearoundheadings"
2222
_ "github.com/jeduden/mdsmith/internal/rules/blanklinearoundlists"
23+
_ "github.com/jeduden/mdsmith/internal/rules/build"
2324
_ "github.com/jeduden/mdsmith/internal/rules/catalog"
2425
_ "github.com/jeduden/mdsmith/internal/rules/concisenessscoring"
2526
_ "github.com/jeduden/mdsmith/internal/rules/crossfilereferenceintegrity"

0 commit comments

Comments
 (0)