Skip to content

Commit 2803cb1

Browse files
committed
Plan 156: composable required-structure schemas (engine)
Adds the composition engine so a file resolved by multiple kinds can get the union of every kind's required-structure schema instead of the last one winning. - internal/schema.Compose merges frontmatter (CUE conjunction for shared keys), sections (merge by heading label; `## ...` slots, bare `?`, preamble stay distinct), Closed (stricter wins), Matcher cardinality (required-by-any wins), Filename (first non-empty; conflicts error), CrossReferences/Acronyms/Index (acronyms: document-wide scope wins). Built against the plan-156 #295 unified `heading:` discriminator model. - The config merge layer accumulates each layer's `schema:` / `inline-schema:` into an append-mode `schema-sources` list via the new rule.SettingsTranslator interface, so internal/config carries no rule-name special case (mirrors rule.ListMerger). - MDS020 loads every source, composes them, validates the composed schema; single-source keeps the legacy file/inline paths; multi-source Fix writes the composed Index side-output. - docs: schemas guide gains a "Composition across kinds" worked example; cross-system doc records the contract. Deferred: wiring directive-rule-readme to compose on top of rule-readme. main now keeps Meta-Information last with Pattern before it (#295/#302); appending directive-proto.md's Pattern after rule-readme's Meta-Information would order it wrong. That schema restructuring (review comment 2) is tracked separately; .mdsmith.yml and directive-proto.md stay on main's standalone directive-rule-readme schema for now. Full suite, lint, and mdsmith check green.
1 parent 024dad8 commit 2803cb1

15 files changed

Lines changed: 3240 additions & 306 deletions

File tree

PLAN.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ footer: |
8383
| 153 || opus | [Unify linkgraph and the LSP symbol index](plan/153_unify-linkgraph-and-lsp-index.md) |
8484
| 154 || sonnet | [arch-fix: extract cross-rule helpers](plan/154_arch-fix-rule-helper-extraction.md) |
8585
| 155 || sonnet | [arch-fix: relocate convention types out of markdownflavor](plan/155_arch-fix-convention-config-ownership.md) |
86-
| 156 | 🔲 | opus | [Composable required-structure schemas across multiple kinds](plan/156_kind-schema-composition.md) |
86+
| 156 | | opus | [Composable required-structure schemas across multiple kinds](plan/156_kind-schema-composition.md) |
8787
| 156 || opus | [Section schema — unify entry shape under `heading:` discriminator](plan/156_schema-entry-unification.md) |
8888
| 157 || sonnet | [Catalog filter by front matter property](plan/157_catalog-where-filter.md) |
8989
| 160 | 🔲 | sonnet | [Claude Code plugin extensions — skills, agents, hooks](plan/160_claude-code-skills-agents-hooks.md) |

docs/development/architecture/cross-system.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,26 @@ not have is a Liskov violation. Push
106106
the flag down to the binary, or drop it
107107
from the shim.
108108

109+
## Schema composition across kinds
110+
111+
When a file resolves to several kinds that each
112+
declare a `required-structure` schema, the
113+
schemas compose. Three rules apply:
114+
115+
- Frontmatter conjoins. A key required by any
116+
kind is required. Shared keys intersect with
117+
CUE `&`.
118+
- Sections merge. Scopes with the same heading
119+
text combine their child lists. Other scopes
120+
append in input order.
121+
- The stricter `closed:` wins.
122+
123+
Multiple kinds can layer schemas. For example,
124+
`directive-rule-readme` builds on top of
125+
`rule-readme`. See the
126+
[Schemas guide](../../guides/schemas.md) for a
127+
worked example.
128+
109129
## Versioning policy (post-1.0)
110130

111131
Today mdsmith is at major 0; the rules

docs/guides/schemas.md

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -436,6 +436,101 @@ A project can mix sources across kinds — some kinds use
436436
inline schemas, others use `proto.md` — but a single
437437
kind must pick one.
438438

439+
## Composition across kinds
440+
441+
A file resolved by multiple kinds that each declare a
442+
`required-structure` schema gets the composition of all
443+
of them — not just the last one. The merge layer
444+
accumulates each kind's `schema:` or `inline-schema:`
445+
into a `schema-sources` list, and MDS020 loads every
446+
source and composes them at check time.
447+
448+
The composition rules are:
449+
450+
- **Frontmatter** keys union across schemas. A key
451+
required by any input is required. Two schemas
452+
constraining the same key get the intersection of
453+
their CUE expressions (joined with `&`).
454+
- **Sections** merge by literal heading text. Scopes
455+
that share the same heading combine their child
456+
lists recursively. Scopes that differ — including
457+
wildcard slots (`{unlisted: true}`), the preamble
458+
(`null`), and the bare `?` wildcard — append in
459+
input order.
460+
- **`closed:`** is OR-ed across inputs. Any scope that
461+
was strict in any input is strict in the composed
462+
scope.
463+
- **`require.filename`** picks the first non-empty
464+
pattern. Conflicting patterns are a config error.
465+
466+
### Worked example: directive-rule-readme + rule-readme
467+
468+
The four directive READMEs in this repository
469+
(`MDS019-catalog`, `MDS021-include`, `MDS038-toc`,
470+
`MDS039-build`) resolve to both `rule-readme` and
471+
`directive-rule-readme`. The first kind contributes
472+
the common rule-README structure (`Config`,
473+
`Examples`, `Meta-Information`); the second only adds
474+
a required `Pattern` section.
475+
476+
```yaml
477+
kinds:
478+
rule-readme:
479+
rules:
480+
required-structure:
481+
schema: internal/rules/proto.md
482+
directive-rule-readme:
483+
rules:
484+
required-structure:
485+
schema: internal/rules/directive-proto.md
486+
487+
kind-assignment:
488+
- glob: ["internal/rules/MDS*/README.md"]
489+
kinds: [rule-readme]
490+
- glob: ["internal/rules/MDS019-catalog/README.md", …]
491+
kinds: [directive-rule-readme]
492+
```
493+
494+
`directive-proto.md` declares only what's specific to
495+
directive rules:
496+
497+
```markdown
498+
---
499+
nature: '"directive"'
500+
---
501+
# {id}: {name}
502+
503+
## ...
504+
505+
## Pattern
506+
507+
### Without the directive
508+
### With the directive
509+
510+
## ...
511+
```
512+
513+
The composed schema requires the union of both
514+
sections lists. `rule-readme`'s `nature` is
515+
`"directive" | "generator" | "content" | "style" |
516+
"structure"`; `directive-rule-readme`'s narrower
517+
`"directive"` intersects to require exactly
518+
`"directive"` on every file resolving to both kinds.
519+
520+
### Picking an input order
521+
522+
The composed section list is the concatenation of each
523+
schema's sections (with same-heading scopes merged).
524+
Order matters for the "last required section" — if the
525+
later schema's required sections must appear before
526+
the earlier schema's required sections in the document,
527+
either reorder the kinds in `kind-assignment` or rewrite
528+
the document so the sections fall in composed order.
529+
The directive READMEs put `Pattern` after
530+
`Meta-Information` precisely so the composed ordering
531+
(`rule-readme` first, `directive-rule-readme` appended)
532+
matches the document layout.
533+
439534
## Diagnostics
440535

441536
Schema diagnostics surface through

internal/config/kinds_test.go

Lines changed: 95 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -471,7 +471,9 @@ func TestValidateKinds_AcceptsValidPathPattern(t *testing.T) {
471471
// settings while injecting the synthetic `path-patterns` entry on
472472
// top of them. Without this, a kind that both disables a rule and
473473
// declares a `path-pattern:` would have its `body.Rules` ignored in
474-
// `kinds resolve` / `--explain` output.
474+
// `kinds resolve` / `--explain` output. The `schema:` setting is
475+
// translated to a `schema-sources` entry so the provenance chain
476+
// reflects the deep-merged form rather than the raw user input.
475477
func TestKindLayerRules_MergesPathPatternWithExistingRules(t *testing.T) {
476478
body := KindBody{
477479
PathPattern: "plan/*.md",
@@ -486,7 +488,11 @@ func TestKindLayerRules_MergesPathPatternWithExistingRules(t *testing.T) {
486488
assert.False(t, out["line-length"].Enabled)
487489
rs := out["required-structure"]
488490
assert.True(t, rs.Enabled)
489-
assert.Equal(t, "plan/proto.md", rs.Settings["schema"],
491+
sources, ok := rs.Settings["schema-sources"].([]any)
492+
require.True(t, ok, "schema-sources must accumulate body.Rules schema source")
493+
require.Len(t, sources, 1)
494+
assert.Equal(t, "plan/proto.md",
495+
sources[0].(map[string]any)["file"],
490496
"existing required-structure settings must be preserved")
491497
list := rs.Settings["path-patterns"].([]any)
492498
require.Len(t, list, 1)
@@ -497,8 +503,7 @@ func TestKindLayerRules_MergesPathPatternWithExistingRules(t *testing.T) {
497503
// that a kind declaring both `schema:` (an inline schema map) and
498504
// `path-pattern:` lands BOTH synthetic settings in the provenance
499505
// layer chain — without this, `kinds resolve` / `--explain` would
500-
// drop the inline-schema leaf even though effectiveRules applies
501-
// it.
506+
// drop the schema source leaf even though effectiveRules applies it.
502507
func TestKindLayerRules_MirrorsInlineSchemaAndPathPattern(t *testing.T) {
503508
body := KindBody{
504509
PathPattern: "plan/*.md",
@@ -512,9 +517,13 @@ func TestKindLayerRules_MirrorsInlineSchemaAndPathPattern(t *testing.T) {
512517
rs := out["required-structure"]
513518
assert.True(t, rs.Enabled)
514519

515-
schema, ok := rs.Settings["inline-schema"].(map[string]any)
516-
require.True(t, ok, "inline-schema must be injected as a map")
517-
assert.Contains(t, schema, "sections")
520+
sources, ok := rs.Settings["schema-sources"].([]any)
521+
require.True(t, ok, "schema-sources must be injected as a list")
522+
require.Len(t, sources, 1)
523+
entry := sources[0].(map[string]any)
524+
inlineMap, ok := entry["inline"].(map[string]any)
525+
require.True(t, ok, "inline entry must wrap the schema map")
526+
assert.Contains(t, inlineMap, "sections")
518527

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

535+
// TestKindLayerRules_TranslatesBodyRulesSchema covers the provenance
536+
// translation of body.Rules' legacy `schema:` setting when the kind
537+
// has neither `KindBody.Schema` (inline map) nor `path-pattern:`.
538+
// The provenance chain must surface `schema-sources` for that case
539+
// too, so explainers don't show a stale `schema:` key.
540+
func TestKindLayerRules_TranslatesBodyRulesSchema(t *testing.T) {
541+
body := KindBody{
542+
Rules: map[string]RuleCfg{
543+
"required-structure": {
544+
Enabled: true,
545+
Settings: map[string]any{
546+
"schema": "plan/proto.md",
547+
},
548+
},
549+
},
550+
}
551+
out := kindLayerRules("plan", body)
552+
rs := out["required-structure"]
553+
sources, ok := rs.Settings["schema-sources"].([]any)
554+
require.True(t, ok)
555+
require.Len(t, sources, 1)
556+
assert.Equal(t, "plan/proto.md", sources[0].(map[string]any)["file"])
557+
assert.NotContains(t, rs.Settings, "schema",
558+
"legacy schema key should be stripped after translation")
559+
}
560+
561+
// TestKindLayerRules_NoTranslationNeededReturnsSameMap exercises the
562+
// fast path: a body whose required-structure entry has no schema
563+
// keys should not allocate a new rules map.
564+
func TestKindLayerRules_NoTranslationNeededReturnsSameMap(t *testing.T) {
565+
body := KindBody{
566+
Rules: map[string]RuleCfg{
567+
"required-structure": {
568+
Enabled: true,
569+
Settings: map[string]any{
570+
"placeholders": []any{"cue-frontmatter"},
571+
},
572+
},
573+
},
574+
}
575+
out := kindLayerRules("plan", body)
576+
// The function returns body.Rules directly in this path because
577+
// neither body.Schema nor body.PathPattern is set, and the
578+
// required-structure entry has no schema source to translate.
579+
assert.Equal(t, body.Rules["required-structure"].Settings["placeholders"],
580+
out["required-structure"].Settings["placeholders"])
581+
assert.NotContains(t, out["required-structure"].Settings, "schema-sources")
582+
}
583+
584+
// TestKindLayerRules_BodyRulesInlineSchemaTranslated covers the
585+
// `inline-schema:` translation in body.Rules (parallel to the
586+
// file-path translation above).
587+
func TestKindLayerRules_BodyRulesInlineSchemaTranslated(t *testing.T) {
588+
body := KindBody{
589+
Rules: map[string]RuleCfg{
590+
"required-structure": {
591+
Enabled: true,
592+
Settings: map[string]any{
593+
"inline-schema": map[string]any{
594+
"sections": []any{
595+
map[string]any{"heading": "Goal"},
596+
},
597+
},
598+
},
599+
},
600+
},
601+
}
602+
out := kindLayerRules("plan", body)
603+
rs := out["required-structure"]
604+
sources, ok := rs.Settings["schema-sources"].([]any)
605+
require.True(t, ok)
606+
require.Len(t, sources, 1)
607+
inlineMap, ok := sources[0].(map[string]any)["inline"].(map[string]any)
608+
require.True(t, ok)
609+
assert.Contains(t, inlineMap, "sections")
610+
assert.NotContains(t, rs.Settings, "inline-schema",
611+
"legacy inline-schema key should be stripped after translation")
612+
}
613+
526614
// --- helpers ---
527615

528616
func loadFromString(t *testing.T, yml string) *Config {

0 commit comments

Comments
 (0)