Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
2 changes: 1 addition & 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 Down
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