Skip to content

Commit 58a92f3

Browse files
committed
schema: unify section entry shape under heading: discriminator (plan 156)
Collapse the section-entry vocabulary to a single discriminator (`heading:` — null, string, or mapping) with one matcher (`regex:`, a CUE raw-interpolation string with `digits` and `fmvar(name)` helpers) and one cardinality field (`repeat: { min, max }`). `sequential:` lives inside the mapping. The schema-level `require.filename:` flattens to top-level `filename:`. Removed keys parse-error with a "removed; see plan 156" diagnostic naming the replacement: - `required:` → `repeat: { min: 0, max: 1 }` (or omit) - `aliases:` → encode disjunction in `regex:` - scope-level `repeats:`/`sequential:`/`min:`/`max:` → `heading.repeat` - `heading: { unlisted: true }` → `heading: { regex: '.+', repeat: { min: 0 } }` - top-level `require:` → top-level `filename:` The five inline kinds in `.mdsmith.yml` and the MDS020 fixtures are rewritten to the new shape. The legacy proto.md heading-row tokens (`# ?`, `## ...`, `## Step {n}`, `## {id}`) desugar to the same matcher shape the inline form produces. `mdsmith check .` and `go tool golangci-lint run` both pass clean. https://claude.ai/code/session_012GGH62fZUzLuzP8T4ocGkJ
1 parent 41e61a5 commit 58a92f3

28 files changed

Lines changed: 1657 additions & 984 deletions

.mdsmith.yml

Lines changed: 13 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -328,9 +328,9 @@ kinds:
328328
closed: false
329329
sections:
330330
- heading: null
331-
required: false
332331
- heading:
333-
unlisted: true
332+
regex: '.+'
333+
repeat: { min: 0 }
334334
cli-command:
335335
schema:
336336
frontmatter:
@@ -339,9 +339,9 @@ kinds:
339339
closed: false
340340
sections:
341341
- heading: null
342-
required: false
343342
- heading:
344-
unlisted: true
343+
regex: '.+'
344+
repeat: { min: 0 }
345345
release-channel:
346346
schema:
347347
frontmatter:
@@ -353,9 +353,9 @@ kinds:
353353
closed: false
354354
sections:
355355
- heading: null
356-
required: false
357356
- heading:
358-
unlisted: true
357+
regex: '.+'
358+
repeat: { min: 0 }
359359
secret-rotation:
360360
schema:
361361
frontmatter:
@@ -371,23 +371,22 @@ kinds:
371371
closed: false
372372
sections:
373373
- heading: null
374-
required: false
375374
- heading:
376-
unlisted: true
375+
regex: '.+'
376+
repeat: { min: 0 }
377377
audit-log:
378378
schema:
379379
frontmatter:
380380
title: 'string & != ""'
381381
"summary?": 'string'
382382
audit-from: '=~"^[0-9a-f]{7,40}$"'
383-
require:
384-
filename: "architecture-audit.md"
383+
filename: "architecture-audit.md"
385384
closed: false
386385
sections:
387386
- heading: null
388-
required: false
389387
- heading:
390-
unlisted: true
388+
regex: '.+'
389+
repeat: { min: 0 }
391390
website-page:
392391
# Hugo content pages under website/content/. Scoped to the
393392
# top level only (no **) so the kind covers hand-authored
@@ -410,9 +409,9 @@ kinds:
410409
closed: false
411410
sections:
412411
- heading: null
413-
required: false
414412
- heading:
415-
unlisted: true
413+
regex: '.+'
414+
repeat: { min: 0 }
416415
rules:
417416
first-line-heading: false
418417
heading-increment: false

PLAN.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ footer: |
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) |
8686
| 156 | 🔲 | opus | [Composable required-structure schemas across multiple kinds](plan/156_kind-schema-composition.md) |
87-
| 156 | 🔲 | opus | [Section schema — unify entry shape under `heading:` discriminator](plan/156_schema-entry-unification.md) |
87+
| 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) |
9090
| 161 | 🔲 | sonnet | [Expose rule maintainability patterns via CLI help and LSP](plan/161_rule-pattern-metadata.md) |

docs/guides/schemas.md

Lines changed: 91 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -37,24 +37,20 @@ templated body content.
3737
kinds:
3838
rfc:
3939
schema:
40+
filename: "RFC-[0-9][0-9][0-9][0-9].md"
4041
frontmatter:
4142
id: '=~"^RFC-[0-9]{4}$"'
4243
status: '"draft" | "ratified" | "deprecated"'
4344
authors: '[...string] & len(authors) >= 1'
44-
require:
45-
filename: "RFC-[0-9][0-9][0-9][0-9].md"
4645
closed: true
4746
sections:
4847
- heading: null
49-
required: false
5048
- heading: "Overview"
51-
required: true
5249
- heading: "Decision"
53-
required: true
5450
- heading:
55-
unlisted: true
51+
regex: '.+'
52+
repeat: { min: 0 }
5653
- heading: "References"
57-
required: true
5854
```
5955
6056
The `frontmatter:` mapping reuses CUE expressions per
@@ -68,40 +64,83 @@ the ISO regex; see
6864
[Schema field types](../reference/schema-types.md)
6965
for the registered names and how they are matched.
7066

71-
`require.filename:` is a glob the document basename
72-
must match.
67+
`filename:` is a glob the document basename must
68+
match. It sits at the top of the schema block (no
69+
`require:` wrapper).
7370

7471
`closed: true` makes the scope strict — unlisted
7572
headings produce a diagnostic. `closed: false` (the
7673
default) tolerates unlisted headings between listed
77-
sections.
74+
sections. `closed:` is only meaningful when the
75+
schema declares `sections:`; setting it on a
76+
frontmatter-only kind is a parse error.
7877

79-
### The `heading:` field
78+
### The `heading:` discriminator
8079

8180
Every section-array entry sets `heading:`. The value
8281
takes one of three shapes:
8382

84-
- **string** — literal heading text. Example:
85-
`heading: "Overview"`. The doc must have a heading
86-
whose text equals that string (or an alias).
8783
- **`null`** — the preamble: content from line 1 up
8884
to the first heading. Only valid as the first entry
89-
in a section list. Carries `required:` /
90-
`closed:` / `rules:` for that range; cannot carry
91-
`aliases:` or nested `sections:`.
92-
- **mapping** — typed match. Today only
93-
`{unlisted: true}` is accepted, declaring a slot
94-
that absorbs zero or more sections the schema did
95-
not list by name. The slot is positional, but
96-
out-of-order detection still claims a heading whose
97-
text matches a later listed scope, so the slot only
98-
absorbs truly-unlisted sections. Slots are
99-
positional-only — they cannot carry `aliases:`,
100-
`sections:`, `rules:`, `closed:`, or `required:`;
101-
the parser rejects those keys. Future work can
102-
extend the mapping form with shapes like
103-
`{any: true}` (match any heading text) or
104-
`{pattern: "..."}` (match a placeholder pattern).
85+
in a section list. Carries `closed:` / `rules:` /
86+
`content:` for that range; rejects `sections:`.
87+
- **string** — sugar for a literal match. The string
88+
is regex-escaped and used as the matcher's pattern,
89+
so `heading: "(WIP)"` matches a heading whose text
90+
is exactly `(WIP)` with the parens taken literally.
91+
Cardinality is one.
92+
- **mapping** — the full form:
93+
`{ regex, repeat?, sequential? }`. `regex:` is
94+
required and is the body of a CUE
95+
raw-interpolation string. `repeat:` bounds the
96+
run; `sequential:` (with the `digits` helper)
97+
asserts ordering.
98+
99+
### The matcher mapping
100+
101+
```yaml
102+
sections:
103+
- heading:
104+
regex: 'Step \#(digits)'
105+
repeat: { min: 1, max: 5 }
106+
sequential: true
107+
sections: [...]
108+
content: [...]
109+
- heading:
110+
regex: '\#(fmvar(id)): \#(fmvar(name))'
111+
- heading:
112+
regex: '.+'
113+
repeat: { min: 0 }
114+
```
115+
116+
`regex:` is whole-string anchored against the
117+
heading's rendered plain text (inline emphasis stripped,
118+
link wrappers unwrapped, code-span backticks dropped).
119+
Backslashes pass through to RE2; interpolation uses
120+
`\#(expr)`. Two helpers are in scope:
121+
122+
- **`digits`** — expands to the named capture
123+
`(?P<n>[0-9]+)`. One per pattern. With
124+
`sequential: true` the validator asserts the
125+
captured numbers are strictly increasing without
126+
gaps.
127+
- **`fmvar(name)`** — looks up the document's
128+
frontmatter field `name`, regex-escapes its value,
129+
and substitutes it.
130+
131+
`repeat:` bounds how many consecutive matching
132+
headings the matcher claims. Omitting `repeat:`
133+
means exactly one; `{ min: 0 }` is zero-or-more;
134+
`{ min: 0, max: 1 }` is optional; `{ min: 1 }` is
135+
one-or-more; bounded forms enforce both bounds.
136+
`repeat: { max: 0 }` and `repeat: { min > max }`
137+
each parse-error.
138+
139+
The wildcard-slot shape — `regex: '.+'` with
140+
`repeat: { min: 0 }` — is positional: it absorbs
141+
zero or more unlisted sections at its slot. A
142+
heading whose text matches a later listed entry is
143+
claimed for that entry, not absorbed by the slot.
105144

106145
### Nested sections
107146

@@ -112,29 +151,25 @@ expresses that as:
112151

113152
```yaml
114153
sections:
115-
- heading: "Symptoms"
116-
required: true
117-
aliases: ["Indicators"]
154+
- heading:
155+
regex: 'Symptoms|Indicators'
118156
- heading: "Diagnosis"
119-
required: true
120157
sections:
121158
- heading: "Step"
122-
required: true
123159
sections:
124160
- heading: "Check"
125-
required: true
126161
- heading: "Expected"
127-
required: true
128-
- heading: "If different"
129-
required: false
130-
- heading: "References"
131-
required: false
162+
- heading:
163+
regex: 'If different'
164+
repeat: { min: 0, max: 1 }
165+
- heading:
166+
regex: 'References'
167+
repeat: { min: 0, max: 1 }
132168
```
133169

134-
`aliases:` lets a heading match alternate texts. A
135-
required scope that lists `aliases: ["Indicators"]`
136-
matches both `## Symptoms` and `## Indicators` in a
137-
document.
170+
A scope that accepts alternate heading texts encodes
171+
the disjunction in its regex: `regex: 'A|B'` matches
172+
a heading whose text is `A` or `B`.
138173

139174
### Section content
140175

@@ -179,10 +214,10 @@ ordered/min/max) emit their own diagnostics but
179214
still consume the slot. Missing required entries
180215
anchor at the section's heading line.
181216

182-
`content:` is not accepted on slot scopes
183-
(`heading: {unlisted: true}`) or on the `?`
184-
wildcard heading — the parser rejects those
185-
shapes today.
217+
`content:` is rejected on a slot scope (the
218+
wildcard-slot shape has no fixed identity to
219+
constrain). Set `content:` only on entries that
220+
match named sections.
186221

187222
### Per-scope rule overrides
188223

@@ -197,7 +232,6 @@ document" without scattering glob overrides.
197232
```yaml
198233
sections:
199234
- heading: "Decision"
200-
required: true
201235
rules:
202236
paragraph-readability:
203237
max-index: 12.0
@@ -364,9 +398,11 @@ filename: "MDS*-*.md"
364398
The `# ?` (or `# {field}: {field}` form) acts as the
365399
title placeholder. `## ...` rows mark wildcard slots.
366400
Front-matter keys map directly to CUE expressions.
367-
`<?require?>` declares the filename pattern. See
368-
[enforcing document structure](directives/enforcing-structure.md)
369-
for the full file-based reference.
401+
`<?require?>` declares the filename pattern. The
402+
heading-row tokens desugar to the same matcher
403+
shape the inline form uses; see the
404+
[section-schema reference](../reference/section-schema.md#protomd-file-syntax)
405+
for the row-by-row mapping.
370406

371407
## Choosing a source
372408

@@ -394,6 +430,8 @@ mismatch`, and `out of order` mean.
394430

395431
## See also
396432

433+
- [Section schema reference](../reference/section-schema.md)
434+
— the entry-shape grammar in full.
397435
- [File kinds](file-kinds.md) — how kinds attach
398436
schemas (and other rule config) to file groups.
399437
- [Enforcing document structure with schemas](directives/enforcing-structure.md)

docs/reference/section-schema.md

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,16 +10,6 @@ summary: >-
1010
---
1111
# Section schema
1212

13-
> **Status: upcoming.** This page documents the
14-
> shape defined by
15-
> [plan 156](../../plan/156_schema-entry-unification.md).
16-
> It is not yet implemented. The current parser
17-
> accepts the older shape documented in the
18-
> [schema guide](../guides/schemas.md). When plan
19-
> 156 lands, this notice is removed and the guide
20-
> is rewritten to drop every reference to the old
21-
> shape.
22-
2313
A **section schema** describes the heading
2414
structure mdsmith expects in a document. It
2515
pairs with frontmatter and filename constraints

0 commit comments

Comments
 (0)