Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ footer: |
| 153 | ✅ | opus | [Unify linkgraph and the LSP symbol index](plan/153_unify-linkgraph-and-lsp-index.md) |
| 154 | ✅ | sonnet | [arch-fix: extract cross-rule helpers](plan/154_arch-fix-rule-helper-extraction.md) |
| 155 | ✅ | sonnet | [arch-fix: relocate convention types out of markdownflavor](plan/155_arch-fix-convention-config-ownership.md) |
| 156 | 🔲 | opus | [Composable required-structure schemas across multiple kinds](plan/156_kind-schema-composition.md) |
| 156 | | opus | [Composable required-structure schemas across multiple kinds](plan/156_kind-schema-composition.md) |
| 156 | ✅ | opus | [Section schema — unify entry shape under `heading:` discriminator](plan/156_schema-entry-unification.md) |
| 157 | ✅ | sonnet | [Catalog filter by front matter property](plan/157_catalog-where-filter.md) |
| 160 | 🔲 | sonnet | [Claude Code plugin extensions — skills, agents, hooks](plan/160_claude-code-skills-agents-hooks.md) |
Expand All @@ -95,4 +95,5 @@ footer: |
| 166 | 🔲 | opus | [Schema-driven data extraction (mdsmith extract)](plan/166_schema-driven-data-extraction.md) |
| 167 | 🔲 | opus | [Custom binding overrides for mdsmith extract](plan/167_custom-binding-overrides.md) |
| 168 | 🔲 | sonnet | [Obsidian Flavored Markdown support](plan/168_obsidian-markdown-support.md) |
| 169 | 🔲 | opus | [Enforce terminal Meta-Information and render it from frontmatter](plan/169_rule-readme-meta-information-sync.md) |
<?/catalog?>
20 changes: 20 additions & 0 deletions docs/development/architecture/cross-system.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,26 @@ not have is a Liskov violation. Push
the flag down to the binary, or drop it
from the shim.

## Schema composition across kinds

When a file resolves to several kinds that each
declare a `required-structure` schema, the
schemas compose. Three rules apply:

- Frontmatter conjoins. A key required by any
kind is required. Shared keys intersect with
CUE `&`.
- Sections merge. Scopes with the same heading
text combine their child lists. Other scopes
append in input order.
- The stricter `closed:` wins.

Multiple kinds can layer schemas. For example,
`directive-rule-readme` builds on top of
`rule-readme`. See the
[Schemas guide](../../guides/schemas.md) for a
worked example.

## Versioning policy (post-1.0)

Today mdsmith is at major 0; the rules
Expand Down
95 changes: 95 additions & 0 deletions docs/guides/schemas.md
Original file line number Diff line number Diff line change
Expand Up @@ -436,6 +436,101 @@ A project can mix sources across kinds — some kinds use
inline schemas, others use `proto.md` — but a single
kind must pick one.

## Composition across kinds

A file resolved by multiple kinds that each declare a
`required-structure` schema gets the composition of all
of them — not just the last one. The merge layer
accumulates each kind's `schema:` or `inline-schema:`
into a `schema-sources` list, and MDS020 loads every
source and composes them at check time.

The composition rules are:

- **Frontmatter** keys union across schemas. A key
required by any input is required. Two schemas
constraining the same key get the intersection of
their CUE expressions (joined with `&`).
- **Sections** merge by literal heading text. Scopes
that share the same heading combine their child
lists recursively. Scopes that differ — including
wildcard slots (`{unlisted: true}`), the preamble
(`null`), and the bare `?` wildcard — append in
input order.
- **`closed:`** is OR-ed across inputs. Any scope that
was strict in any input is strict in the composed
scope.
- **`require.filename`** picks the first non-empty
pattern. Conflicting patterns are a config error.

### Worked example: directive-rule-readme + rule-readme

The four directive READMEs in this repository
(`MDS019-catalog`, `MDS021-include`, `MDS038-toc`,
`MDS039-build`) resolve to both `rule-readme` and
`directive-rule-readme`. The first kind contributes
the common rule-README structure (`Config`,
`Examples`, `Meta-Information`); the second only adds
a required `Pattern` section.

```yaml
kinds:
rule-readme:
rules:
required-structure:
schema: internal/rules/proto.md
directive-rule-readme:
rules:
required-structure:
schema: internal/rules/directive-proto.md

kind-assignment:
- glob: ["internal/rules/MDS*/README.md"]
kinds: [rule-readme]
- glob: ["internal/rules/MDS019-catalog/README.md", …]
kinds: [directive-rule-readme]
```

`directive-proto.md` declares only what's specific to
directive rules:

```markdown
---
nature: '"directive"'
---
# {id}: {name}

## ...

## Pattern

### Without the directive
### With the directive

## ...
```

The composed schema requires the union of both
sections lists. `rule-readme`'s `nature` is
`"directive" | "generator" | "content" | "style" |
"structure"`; `directive-rule-readme`'s narrower
`"directive"` intersects to require exactly
`"directive"` on every file resolving to both kinds.

### Picking an input order

The composed section list is the concatenation of each
schema's sections (with same-heading scopes merged).
Order matters for the "last required section" — if the
later schema's required sections must appear before
the earlier schema's required sections in the document,
either reorder the kinds in `kind-assignment` or rewrite
the document so the sections fall in composed order.
The directive READMEs put `Pattern` after
`Meta-Information` precisely so the composed ordering
(`rule-readme` first, `directive-rule-readme` appended)
matches the document layout.

## Diagnostics

Schema diagnostics surface through
Expand Down
102 changes: 95 additions & 7 deletions internal/config/kinds_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -471,7 +471,9 @@ func TestValidateKinds_AcceptsValidPathPattern(t *testing.T) {
// settings while injecting the synthetic `path-patterns` entry on
// top of them. Without this, a kind that both disables a rule and
// declares a `path-pattern:` would have its `body.Rules` ignored in
// `kinds resolve` / `--explain` output.
// `kinds resolve` / `--explain` output. The `schema:` setting is
// translated to a `schema-sources` entry so the provenance chain
// reflects the deep-merged form rather than the raw user input.
func TestKindLayerRules_MergesPathPatternWithExistingRules(t *testing.T) {
body := KindBody{
PathPattern: "plan/*.md",
Expand All @@ -486,7 +488,11 @@ func TestKindLayerRules_MergesPathPatternWithExistingRules(t *testing.T) {
assert.False(t, out["line-length"].Enabled)
rs := out["required-structure"]
assert.True(t, rs.Enabled)
assert.Equal(t, "plan/proto.md", rs.Settings["schema"],
sources, ok := rs.Settings["schema-sources"].([]any)
require.True(t, ok, "schema-sources must accumulate body.Rules schema source")
require.Len(t, sources, 1)
assert.Equal(t, "plan/proto.md",
sources[0].(map[string]any)["file"],
"existing required-structure settings must be preserved")
list := rs.Settings["path-patterns"].([]any)
require.Len(t, list, 1)
Expand All @@ -497,8 +503,7 @@ func TestKindLayerRules_MergesPathPatternWithExistingRules(t *testing.T) {
// that a kind declaring both `schema:` (an inline schema map) and
// `path-pattern:` lands BOTH synthetic settings in the provenance
// layer chain — without this, `kinds resolve` / `--explain` would
// drop the inline-schema leaf even though effectiveRules applies
// it.
// drop the schema source leaf even though effectiveRules applies it.
func TestKindLayerRules_MirrorsInlineSchemaAndPathPattern(t *testing.T) {
body := KindBody{
PathPattern: "plan/*.md",
Expand All @@ -512,9 +517,13 @@ func TestKindLayerRules_MirrorsInlineSchemaAndPathPattern(t *testing.T) {
rs := out["required-structure"]
assert.True(t, rs.Enabled)

schema, ok := rs.Settings["inline-schema"].(map[string]any)
require.True(t, ok, "inline-schema must be injected as a map")
assert.Contains(t, schema, "sections")
sources, ok := rs.Settings["schema-sources"].([]any)
require.True(t, ok, "schema-sources must be injected as a list")
require.Len(t, sources, 1)
entry := sources[0].(map[string]any)
inlineMap, ok := entry["inline"].(map[string]any)
require.True(t, ok, "inline entry must wrap the schema map")
assert.Contains(t, inlineMap, "sections")

list, ok := rs.Settings["path-patterns"].([]any)
require.True(t, ok)
Expand All @@ -523,6 +532,85 @@ func TestKindLayerRules_MirrorsInlineSchemaAndPathPattern(t *testing.T) {
list[0].(map[string]any)["pattern"])
}

// TestKindLayerRules_TranslatesBodyRulesSchema covers the provenance
// translation of body.Rules' legacy `schema:` setting when the kind
// has neither `KindBody.Schema` (inline map) nor `path-pattern:`.
// The provenance chain must surface `schema-sources` for that case
// too, so explainers don't show a stale `schema:` key.
func TestKindLayerRules_TranslatesBodyRulesSchema(t *testing.T) {
body := KindBody{
Rules: map[string]RuleCfg{
"required-structure": {
Enabled: true,
Settings: map[string]any{
"schema": "plan/proto.md",
},
},
},
}
out := kindLayerRules("plan", body)
rs := out["required-structure"]
sources, ok := rs.Settings["schema-sources"].([]any)
require.True(t, ok)
require.Len(t, sources, 1)
assert.Equal(t, "plan/proto.md", sources[0].(map[string]any)["file"])
assert.NotContains(t, rs.Settings, "schema",
"legacy schema key should be stripped after translation")
}

// TestKindLayerRules_NoTranslationNeededReturnsSameMap exercises the
// fast path: a body whose required-structure entry has no schema
// keys should not allocate a new rules map.
func TestKindLayerRules_NoTranslationNeededReturnsSameMap(t *testing.T) {
body := KindBody{
Rules: map[string]RuleCfg{
"required-structure": {
Enabled: true,
Settings: map[string]any{
"placeholders": []any{"cue-frontmatter"},
},
},
},
}
out := kindLayerRules("plan", body)
// The function returns body.Rules directly in this path because
// neither body.Schema nor body.PathPattern is set, and the
// required-structure entry has no schema source to translate.
assert.Equal(t, body.Rules["required-structure"].Settings["placeholders"],
out["required-structure"].Settings["placeholders"])
assert.NotContains(t, out["required-structure"].Settings, "schema-sources")
}

// TestKindLayerRules_BodyRulesInlineSchemaTranslated covers the
// `inline-schema:` translation in body.Rules (parallel to the
// file-path translation above).
func TestKindLayerRules_BodyRulesInlineSchemaTranslated(t *testing.T) {
body := KindBody{
Rules: map[string]RuleCfg{
"required-structure": {
Enabled: true,
Settings: map[string]any{
"inline-schema": map[string]any{
"sections": []any{
map[string]any{"heading": "Goal"},
},
},
},
},
},
}
out := kindLayerRules("plan", body)
rs := out["required-structure"]
sources, ok := rs.Settings["schema-sources"].([]any)
require.True(t, ok)
require.Len(t, sources, 1)
inlineMap, ok := sources[0].(map[string]any)["inline"].(map[string]any)
require.True(t, ok)
assert.Contains(t, inlineMap, "sections")
assert.NotContains(t, rs.Settings, "inline-schema",
"legacy inline-schema key should be stripped after translation")
}

// --- helpers ---

func loadFromString(t *testing.T, yml string) *Config {
Expand Down
Loading
Loading