Skip to content

Commit cd39d3b

Browse files
jedudenclaude
andauthored
Plan 209: Convention-per-file config under .mdsmith/conventions/ (#431)
* Start plan 209: Convention-per-file config under `.mdsmith/conventions/` * config: add discoverConventions for .mdsmith/conventions/ Mirror discoverKinds (plan 208) for user-defined conventions: walk .mdsmith/conventions/*.{yaml,yml} at the workspace root, decode each into a UserConvention with strict KnownFields decoding plus the anchor/alias guard, and key the result by basename. Reject bad basenames, subdirectories, .yaml/.yml collisions, and unknown top-level keys, each error naming the offending file. Add SourcePath to UserConvention (parallel to KindBody.SourcePath) so a later commit can surface a convention's defining file in provenance output. Plan 209 task 1. https://claude.ai/code/session_01NGDnJbnrZFW2SsP66QY462 * config: merge .mdsmith/conventions/ files into Load Wire discoverConventions into Load: tag inline conventions with the loaded config path for uniform provenance, then merge file-defined conventions from .mdsmith/conventions/<name>.yaml. A name colliding with a built-in convention, or with an inline convention, is a config error that names the offending file(s) so the user can resolve it. Plan 209 task 2. https://claude.ai/code/session_01NGDnJbnrZFW2SsP66QY462 * config: fold inline kind/convention tagging into merge helpers Loads funlen budget (60 lines) was exceeded once plan 209s convention block landed in Load. Move the inline-entry SourcePath tagging out of Load and into mergeKindFiles and mergeConventionFiles, ahead of each helpers empty-discovery early return, so Load just delegates to the two helpers. Behavior is unchanged: inline kinds and conventions are still tagged with the config path before file-defined entries merge, so a name-collision diagnostic still quotes both sources. No helper has a direct unit test; all SourcePath assertions go through Load. https://claude.ai/code/session_01NGDnJbnrZFW2SsP66QY462 * test(config): tighten plan-209 convention-file coverage; fix stale comment From a two-pass code review of the convention-files diff: - builtin-collision test iterates convention.Names() instead of a hardcoded {portable,github,plain} subset, so newer built-ins (obsidian, parity) are covered and a reserved-set drift is caught. - DiscoverConventions_LoadsFullBody now asserts body.SourcePath (the field that actually flows into cfg.Conventions), not just the redundant discoveredConvention.sourcePath. - add TestLoad_InlineAndFileConventionsCoexist: a non-colliding inline convention and a .yml file convention both survive the merge with correct SourcePaths, guarding the nil-map merge and the .yml Load path. - fix a stale doc comment in parseKindFile (UnmarshalSafe -> RejectYAMLAliases) to match the actual call and the convention sibling. No production behavior change. https://claude.ai/code/session_01NGDnJbnrZFW2SsP66QY462 * config: harden convention-file reading (.mdsmith/conventions/) Five robustness fixes from the code review (the kind-file sibling gets the same in the next commit): - size cap: parseConventionFile reads via readLimitedConfig (1 MB cap) instead of an unbounded os.ReadFile, matching how .mdsmith.yml is read. - reject symlinks: a symlink reports IsDir()==false (lstat), so it bypassed the subdirectory guard and a symlinked file could be read off the workspace. Now rejected with a clear error. - accept .yaml/.yml case-insensitively so a .YAML file is not silently skipped. - deterministic collision errors: iterate discovered conventions in sorted name order so the reported file is stable across runs rather than depending on map order. - empty/comments-only file now errors with a clear "empty convention file" message instead of the decoder bare "EOF". (An empty file cannot be a no-op convention: applyConvention requires a flavor, so a clear error is the right outcome.) https://claude.ai/code/session_01NGDnJbnrZFW2SsP66QY462 * config: harden kind-file reading (.mdsmith/kinds/) Apply the same five robustness fixes as the convention sibling to the kind-file path, keeping the two in parity: - size cap via readLimitedConfig (1 MB) instead of an unbounded os.ReadFile. - reject symlinks under .mdsmith/kinds/ (a symlink bypassed the subdirectory guard via lstat IsDir()==false). - accept .yaml/.yml case-insensitively so a .YAML file is not silently skipped. - deterministic collision errors: iterate discovered kinds in sorted name order. - empty/comments-only file errors clearly ("empty kind file") instead of the decoder bare "EOF" (empty kind files already errored; this only clarifies the message). Tests added for each; full suite green. https://claude.ai/code/session_01NGDnJbnrZFW2SsP66QY462 * config,kindsout: report active convention + its source path in kinds resolve Plan 209 tasks 3-4 (provenance + CLI). `mdsmith kinds resolve <file>` now prints the active convention and, for a user convention, the file that defined it — parallel to how each kind shows `defined-in`. - config: ResolveFile populates a new FileResolution.Convention (ResolvedConvention: name, user flag, source path) via resolveConvention. A built-in is reported by name with no path; no selection yields the zero value. - kindsout: WriteFileResolutionText prints a `convention:` line; --json carries a `convention` object with name + user + source-path (mirroring each resolved kind's source-path). - merge: copyUserConventions dropped UserConvention.SourcePath, so the path was blanked once the CLI merged loaded config onto defaults. Copy it through, matching copyKinds. Tests: resolveConvention unit cases; kindsout text + JSON (user, built-in, none); copyUserConventions preserves SourcePath. https://claude.ai/code/session_01NGDnJbnrZFW2SsP66QY462 * test(integration): convention-file contract + equivalence tests Plan 209 task 5, mirroring the kind-file integration pair. - contract: layout/basename, bad basename, subdirectory, dual-source, built-in-name collision, unknown key, .yaml/.yml collision, both extensions, SourcePath populated, and SourcePath surviving Load -> Merge -> ResolveFile (the CLI path). - equivalence: a file convention and the equivalent inline convention emit byte-equal diagnostics on the same doc (acceptance criterion #1), with a non-empty guard against a trivial match. https://claude.ai/code/session_01NGDnJbnrZFW2SsP66QY462 * docs: convention files under .mdsmith/conventions/ Plan 209 task 6. New reference page modeled on kind-files.md: directory layout, file shape (flavor + rules), basename/extension/subdirectory/symlink rules, composition with .mdsmith.yml (inline coexistence, dual-source + built-in-name errors), a "split an inline convention" recipe, and an Audit section showing the kinds-resolve convention line + --json convention object. Also: a .mdsmith/conventions/ row in the cross-system boundaries table, a cross-link from the conventions reference, and the regenerated catalog/include blocks (index.md, CLAUDE.md, AGENTS.md, copilot-instructions.md). https://claude.ai/code/session_01NGDnJbnrZFW2SsP66QY462 * plan 209: complete — convention-per-file config All six in-scope tasks done and all acceptance criteria verified (byte-equal effective rules, dual-source/basename/subdir/built-in collision errors, kinds-resolve source-path, full test suite, golangci-lint, mdsmith check). Status 🔳 → ✅; regenerated the PLAN.md index. https://claude.ai/code/session_01NGDnJbnrZFW2SsP66QY462 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 2011986 commit cd39d3b

23 files changed

Lines changed: 1608 additions & 37 deletions

.github/copilot-instructions.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,7 @@ row: "- [{summary}](../{filename})"
112112
- [Select Markdown files by a CUE expression on front matter.](../docs/reference/cli/query.md)
113113
- [Rename a heading or link-reference label and rewrite every dependent edit.](../docs/reference/cli/rename.md)
114114
- [Print the mdsmith build version and exit.](../docs/reference/cli/version.md)
115+
- [Each file under `.mdsmith/conventions/` declares one user convention. The basename is the convention name; the file body carries a `flavor:` plus a `rules:` map. Sits alongside inline `conventions.<name>:` in `.mdsmith.yml`.](../docs/reference/convention-files.md)
115116
- [Built-in Markdown conventions, the rule presets each one applies, and how user config layers on top via deep-merge.](../docs/reference/conventions.md)
116117
- [Glob pattern syntax across mdsmith config, directives, and CLI argument expansion, with the supported exclusion semantics for each surface.](../docs/reference/globs.md)
117118
- [Look up exact CLI commands, config glob and schema syntax, the built-in conventions, and the section-schema grammar.](../docs/reference/index.md)

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,7 @@ row: "- [{summary}]({filename})"
118118
- [Select Markdown files by a CUE expression on front matter.](docs/reference/cli/query.md)
119119
- [Rename a heading or link-reference label and rewrite every dependent edit.](docs/reference/cli/rename.md)
120120
- [Print the mdsmith build version and exit.](docs/reference/cli/version.md)
121+
- [Each file under `.mdsmith/conventions/` declares one user convention. The basename is the convention name; the file body carries a `flavor:` plus a `rules:` map. Sits alongside inline `conventions.<name>:` in `.mdsmith.yml`.](docs/reference/convention-files.md)
121122
- [Built-in Markdown conventions, the rule presets each one applies, and how user config layers on top via deep-merge.](docs/reference/conventions.md)
122123
- [Glob pattern syntax across mdsmith config, directives, and CLI argument expansion, with the supported exclusion semantics for each surface.](docs/reference/globs.md)
123124
- [Look up exact CLI commands, config glob and schema syntax, the built-in conventions, and the section-schema grammar.](docs/reference/index.md)

CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@ row: "- [{summary}]({filename})"
104104
- [Select Markdown files by a CUE expression on front matter.](docs/reference/cli/query.md)
105105
- [Rename a heading or link-reference label and rewrite every dependent edit.](docs/reference/cli/rename.md)
106106
- [Print the mdsmith build version and exit.](docs/reference/cli/version.md)
107+
- [Each file under `.mdsmith/conventions/` declares one user convention. The basename is the convention name; the file body carries a `flavor:` plus a `rules:` map. Sits alongside inline `conventions.<name>:` in `.mdsmith.yml`.](docs/reference/convention-files.md)
107108
- [Built-in Markdown conventions, the rule presets each one applies, and how user config layers on top via deep-merge.](docs/reference/conventions.md)
108109
- [Glob pattern syntax across mdsmith config, directives, and CLI argument expansion, with the supported exclusion semantics for each surface.](docs/reference/globs.md)
109110
- [Look up exact CLI commands, config glob and schema syntax, the built-in conventions, and the section-schema grammar.](docs/reference/index.md)

PLAN.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,7 @@ footer: |
134134
| 206 | 🔲 | | [Document cue/ in architecture layering map](plan/206_arch-fix-cue-types-docs.md) |
135135
| 207 || sonnet | [LSP fix preview via ChangeAnnotation](plan/207_lsp-fix-preview.md) |
136136
| 208 || opus | [Kind-per-file config under `.mdsmith/kinds/`](plan/208_kind-files.md) |
137-
| 209 | 🔲 | opus | [Convention-per-file config under `.mdsmith/conventions/`](plan/209_convention-files.md) |
137+
| 209 | | opus | [Convention-per-file config under `.mdsmith/conventions/`](plan/209_convention-files.md) |
138138
| 210 || opus | [Single source of truth for product messaging via `mdsmith extract`](plan/210_messaging-source-of-truth.md) |
139139
| 211 | 🔳 | opus | [`<?include?>` projects any typed value of any kind via `extract`](plan/211_include-extract-value.md) |
140140
| 212 | 🔲 | opus | [`mdsmith extract` projects paragraph inline spans as data](plan/212_extract-inline-spans.md) |

docs/development/architecture/cross-system.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ doc" column says.
3535
| CLI flags + exit codes | `cmd/mdsmith` | [CLI reference](../../reference/cli.md) | shell scripts, CI, git hooks |
3636
| `.mdsmith.yml` schema | `internal/config` | [Conventions](../../reference/conventions.md) | every project using mdsmith |
3737
| `.mdsmith/kinds/` directory | `internal/config` | [Kind files](../../reference/kind-files.md) | every project using mdsmith |
38+
| `.mdsmith/conventions/` directory | `internal/config` | [Convention files](../../reference/convention-files.md) | every project using mdsmith |
3839
| Generated section markers | `internal/archetype/gensection` | [Generated sections](../../background/concepts/generated-section.md) | every project's Markdown files |
3940
| Claude plugin manifest (published) | `editors/claude-code/.claude-plugin/plugin.json` | [Install: Claude plugin](../../guides/install.md) | end users via Claude Code marketplace |
4041
| Claude plugin manifest (contributors) | `editors/claude-code-dev/.claude-plugin/plugin.json` | [editors/claude-code-dev/README.md](../../../editors/claude-code-dev/README.md) | mdsmith contributors |

docs/reference/convention-files.md

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
---
2+
title: Convention files under `.mdsmith/conventions/`
3+
weight: 22
4+
summary: >-
5+
Each file under `.mdsmith/conventions/` declares one
6+
user convention. The basename is the convention name;
7+
the file body carries a `flavor:` plus a `rules:` map.
8+
Sits alongside inline `conventions.<name>:` in
9+
`.mdsmith.yml`.
10+
---
11+
# Convention files under `.mdsmith/conventions/`
12+
13+
A **convention file** is a YAML file under
14+
`.mdsmith/conventions/` whose basename is the
15+
convention's name and whose body is the full convention
16+
bundle. One file per convention, no nesting. The
17+
directory sits next to `.mdsmith.yml` at the workspace
18+
root.
19+
20+
```text
21+
.mdsmith.yml # unchanged
22+
.mdsmith/
23+
kinds/ # plan 208
24+
audit-log.yaml
25+
conventions/
26+
portable-strict.yaml
27+
long-form-docs.yaml
28+
```
29+
30+
Use convention files when the `conventions:` block has
31+
grown large. Each rule edit dirties the same
32+
`.mdsmith.yml` as every other config change. Splitting
33+
conventions into one file each isolates the history. The
34+
read path shortens too: open `portable-strict.yaml` to
35+
see the whole `portable-strict` convention.
36+
37+
Built-in conventions (`portable`, `github`, `plain`, and
38+
the rest listed in the
39+
[conventions reference](conventions.md)) stay compiled
40+
into the binary. Convention files hold only the
41+
conventions you define.
42+
43+
## File shape
44+
45+
The file body matches the inline `conventions.<name>:`
46+
body — a [`UserConvention`](conventions.md): a `flavor:`
47+
key plus a `rules:` map. The `rules:` block uses the same
48+
schema as the top-level `rules:` block. A key outside
49+
that set is a config error naming the key and file.
50+
51+
```yaml
52+
# .mdsmith/conventions/portable-strict.yaml
53+
flavor: commonmark
54+
rules:
55+
line-length:
56+
max: 72
57+
no-bare-urls: true
58+
no-inline-html:
59+
allow: [details, summary]
60+
```
61+
62+
A convention must declare a `flavor:` to be selectable.
63+
The flavor must be a recognised flavor string such as
64+
`commonmark`, `gfm`, or `goldmark`. Each key under
65+
`rules:` must name a registered rule and pass that rule's
66+
own schema check, exactly as an inline convention does.
67+
68+
Select the convention the same way as any other — with
69+
the top-level `convention:` key in `.mdsmith.yml`:
70+
71+
```yaml
72+
convention: portable-strict
73+
```
74+
75+
The `convention:` selector stays in `.mdsmith.yml`; it is
76+
not externalized. A convention file only supplies the
77+
bundle, never picks it.
78+
79+
## Basename rule
80+
81+
The convention's name is the basename minus extension.
82+
The basename must match `[a-z][a-z0-9-]*` — lower case,
83+
starting with a letter, with optional hyphen-separated
84+
segments. The rule applies only to filenames (OS case
85+
folding, path safety); inline `conventions.<name>:` keys
86+
stay unvalidated.
87+
88+
Both `*.yaml` and `*.yml` are scanned. Two convention
89+
files with the same basename across the two extensions is
90+
a config error naming both files.
91+
92+
Subdirectories under `.mdsmith/conventions/` are
93+
rejected, as are symlinks. A file larger than 1 MB is
94+
rejected. One convention per file, flat layout.
95+
96+
## Composition with `.mdsmith.yml`
97+
98+
`conventions.<name>:` blocks inside `.mdsmith.yml` remain
99+
a first-class source. A project can mix inline and
100+
file-defined conventions freely.
101+
102+
The same convention name declared in **both** a file and
103+
inline is a config error naming both sources. The two
104+
sources do **not** merge — a merged convention would
105+
defeat the "read one file to know one convention"
106+
property convention files ship.
107+
108+
A name colliding with a built-in convention (`portable`,
109+
`github`, `plain`, and the others in the
110+
[conventions reference](conventions.md)) is a config
111+
error: the built-in name is reserved. This keeps the
112+
built-in names stable across docs and tutorials.
113+
114+
The top-level `convention:` selector and the
115+
`overrides:`, `kinds:`, and `ignore:` blocks all stay in
116+
`.mdsmith.yml`. A `convention: <name>` entry references a
117+
convention by name — inline or file convention — with no
118+
extra wiring.
119+
120+
## Splitting an inline convention
121+
122+
To move an existing inline convention into its own file,
123+
cut the body under `conventions.<name>:` and drop the
124+
inline entry. Take this `.mdsmith.yml`:
125+
126+
```yaml
127+
conventions:
128+
our-team:
129+
flavor: gfm
130+
rules:
131+
no-inline-html:
132+
allow: [details, summary, kbd]
133+
list-marker-style:
134+
style: dash
135+
136+
convention: our-team
137+
```
138+
139+
Write the body to a file named for the convention, and
140+
delete the `conventions:` block:
141+
142+
```yaml
143+
# .mdsmith/conventions/our-team.yaml
144+
flavor: gfm
145+
rules:
146+
no-inline-html:
147+
allow: [details, summary, kbd]
148+
list-marker-style:
149+
style: dash
150+
```
151+
152+
The `convention: our-team` selector stays in
153+
`.mdsmith.yml`. Move the body, do not copy it: declaring
154+
the same name both inline and in a file is a config error
155+
naming both sources. The effective rules are byte-equal
156+
either way.
157+
158+
## Audit
159+
160+
`mdsmith kinds resolve <file>` prints the active
161+
convention and the file that defined it, so you can jump
162+
straight to the right source:
163+
164+
```text
165+
file: docs/guide.md
166+
effective kinds:
167+
(none)
168+
convention: portable-strict (user) defined-in .mdsmith/conventions/portable-strict.yaml
169+
```
170+
171+
The `(user)` tag marks a user-defined convention.
172+
Built-in conventions carry no tag and no defining-source
173+
path — they are compiled into the binary.
174+
175+
The JSON shape (`--json`) carries a `convention` object
176+
with `name` and `source-path` keys. That parallels each
177+
resolved kind's `source-path`, so editor integrations can
178+
key off a stable field.
179+
180+
`mdsmith kinds resolve <file>` also shows the full merge
181+
chain for every rule, including the `convention.<name>`
182+
layer. Use it to confirm which value won and where it
183+
came from.
184+
185+
See the [conventions reference](conventions.md) for the
186+
built-in bundles, the merge order, and how presets layer
187+
with your top-level rules.

docs/reference/conventions.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -233,7 +233,9 @@ convention: our-team
233233

234234
Each entry is a `{ flavor, rules }` pair. The `rules`
235235
block uses the same schema as the top-level `rules:`
236-
block.
236+
block. To lift one out of `.mdsmith.yml` into its own
237+
file under `.mdsmith/conventions/<name>.yaml`, see the
238+
[convention files reference](convention-files.md).
237239

238240
### Validation
239241

docs/reference/index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ row: "- [{summary}]({filename})"
3434
- [Select Markdown files by a CUE expression on front matter.](cli/query.md)
3535
- [Rename a heading or link-reference label and rewrite every dependent edit.](cli/rename.md)
3636
- [Print the mdsmith build version and exit.](cli/version.md)
37+
- [Each file under `.mdsmith/conventions/` declares one user convention. The basename is the convention name; the file body carries a `flavor:` plus a `rules:` map. Sits alongside inline `conventions.<name>:` in `.mdsmith.yml`.](convention-files.md)
3738
- [Built-in Markdown conventions, the rule presets each one applies, and how user config layers on top via deep-merge.](conventions.md)
3839
- [Glob pattern syntax across mdsmith config, directives, and CLI argument expansion, with the supported exclusion semantics for each surface.](globs.md)
3940
- [Each file under `.mdsmith/kinds/` declares one kind. The basename is the kind name; the file body carries the full `KindBody` — schema, rules, `path-pattern:`, `extends:`. Sits alongside inline `kinds.<name>:` in `.mdsmith.yml`.](kind-files.md)

internal/config/config.go

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,16 +25,26 @@ var ValidCategories = []string{
2525
// discovery when no file arguments are given on the command line.
2626
var DefaultFiles = []string{"**/*.md", "**/*.markdown"}
2727

28-
// UserConvention is a user-defined convention bundle declared in the
29-
// top-level `conventions:` block in .mdsmith.yml. It has the same
30-
// { flavor, rules } shape as the built-in convention table.
28+
// UserConvention is a user-defined convention bundle declared either
29+
// inline under the top-level `conventions:` block in .mdsmith.yml or
30+
// in a standalone file under `.mdsmith/conventions/<name>.yaml` (plan
31+
// 209). It has the same { flavor, rules } shape as the built-in
32+
// convention table.
3133
type UserConvention struct {
3234
// Flavor is the Markdown flavor MDS034 should validate against
3335
// (e.g. "commonmark", "gfm", "goldmark").
3436
Flavor string `yaml:"flavor"`
3537
// Rules maps rule names to their presets, using the same schema
3638
// as the top-level `rules:` block.
3739
Rules map[string]RuleCfg `yaml:"rules,omitempty"`
40+
41+
// SourcePath is the workspace-absolute path of the file that
42+
// defined this convention — either `.mdsmith.yml` for inline
43+
// conventions or `.mdsmith/conventions/<name>.{yaml,yml}` for
44+
// file-defined conventions (plan 209). Not serialized to YAML;
45+
// populated by Load. Provenance surfaces it on the convention
46+
// layer; CLI resolve/why prints it next to the layer.
47+
SourcePath string `yaml:"-"`
3848
}
3949

4050
// Config is the top-level configuration.

0 commit comments

Comments
 (0)