| id | 149 | |
|---|---|---|
| title | Section content schema for non-heading AST nodes | |
| status | ✅ | |
| model | opus | |
| depends-on |
|
|
| summary | Add a `content:` field at the section scope level so a schema can declare a positional sequence of required AST node entries (code blocks of a given language, tables with named columns, lists with item bounds, paragraphs) inside a matched section. The `sections:` array stays heading-rooted; the new `content:` array carries the heterogeneous AST-node constraints one level deeper. |
Let a schema describe what must appear inside a matched section. A scope can list required code blocks, tables, lists, and paragraphs. The validator walks the section's AST body in order. Missing, extra, or out-of-order nodes surface alongside the existing heading-tree diagnostics.
Plan 146 ships the scope tree with one
positional list: sections:. Every entry is
heading-rooted. Plan 142 covers prose
constraints as per-scope rules — forbidden
text, required mentions, word caps. That plan
deliberately introduces no new schema shape;
it routes through the existing rules:
override block.
The remaining gap is positional AST-node
constraints below the heading level. Examples:
"Examples must contain a YAML code block";
"Settings must contain a table with Setting
and Default columns"; "Diagnosis steps must
end with a paragraph". These constraints are
position-sensitive within the section, so they
can't be expressed as document-wide rules. And
they aren't headings, so sections: doesn't
fit. They live as a second positional array on
the scope, separate from sections:.
The shape choice is settled. The sections:
array stays uniformly heading-rooted; the new
content: field carries non-heading AST nodes
one level deeper inside a section. Mixing
both into one heterogeneous array (kind:
discriminator on every entry) was rejected as
overly verbose for the common case and as
conflating layout-of-headings with
content-within-a-section. See plan 146's
discussion of section-array shape.
- Content-of-content: nested constraints inside a code block, table cells, list items. Plan 142 covers prose-text rules; this plan covers node-shape constraints only.
- Schema for CUE-typed table rows. Tables get required column NAMES today; values are out of scope.
- Frontmatter-body sync against
content:entries (the{field}interpolation in file-based schemas stays heading-only). - Auto-fix for missing content nodes.
Each scope in sections: may carry a
content: list alongside its existing
heading: / sections: / closed: / rules:
keys. The validator runs content:
validation only when the scope matched a doc
heading; the search is bounded by the
section's line range (same window the
per-scope rule walker uses).
sections:
- heading: "Examples"
required: true
content:
- kind: code-block
lang: yaml
- kind: paragraph
required: falsekind: |
Extra keys (all optional) | Matches |
|---|---|---|
code-block |
lang: (string; exact match today) |
An ast.FencedCodeBlock whose info matches. |
table |
columns: [str, ...] (exact header names) |
A GFM table whose first row equals columns. |
list |
ordered: bool, min-items, max-items |
An ast.List whose ordered flag matches. |
paragraph |
(none today) | An ast.Paragraph not recognised as a table. |
unlisted |
(none) | A positional slot — see below. |
Every entry accepts required: true|false.
The default is true. The parser rejects
required: on kind: unlisted outright.
The parent scope's closed: flag drives
diagnostics for trailing or intervening
unlisted nodes. A per-entry closed: field
is not shipped. Entries name single AST
blocks; there is no child content to be
closed against.
content: follows the same out-of-order +
unlisted-slot semantics the scope-tree
validator already implements:
- Entries match in declared order; later entries can be claimed out-of-order when the doc has them early, with a diagnostic.
unlisted: trueis the positional slot (same as plan 146's section-array slot, renamed from"..."); intervening non-matching nodes inside the slot are tolerated; the next listed entry's match ends the slot.closed: trueon the parent scope makes unlisted nodes outside aunlistedslot a diagnostic;closed: false(default) tolerates them silently.
All new diagnostics emit through
lint.Diagnostic. They reuse the existing
MDS020 required-structure rule ID. Messages
name the expected kind and any constraint:
missing required content "code-block lang=yaml" inside ## Examplesunexpected content "table" inside ## Examples (expected "paragraph")content "table" out of order: expected after "code-block lang=yaml"code block language %q does not match required %qtable headers %v do not match required %v
The diagnostic line points at the offending AST node when present; otherwise at the section's heading line.
- Define
ContentEntryplus aContent []ContentEntryfield onScope. Each entry carriesKind,Required, and a small set of kind-specific fields directly on the struct (Lang,Columns,Ordered/OrderedSet,MinItems,MaxItems) — the design uses a flat discriminated record rather than five nested kind-specific sub-structs because the constraint sets are tiny and a flat record keeps the parser, validator, and describer one function per field. - Inline parser: extend
parseInlineScopeEntryto read acontent:mapping value; reject unknownkind:strings; reject content-on-a- wildcard / content-on-a-?-heading until the validator decides what those mean. - Validator: after a scope claims its doc
heading, walk the section's AST body
(between the heading and the next sibling
or parent) and match each
ContentEntryin order, mirroring the heading-tree matching rules. - Diagnostic messages: add the five new
message templates listed above to MDS020.
Reuse
formatHeading/ scope-line helpers so output stays consistent. - File-based parser: leave
proto.mdalone for now. The legacy heading-template path stays unchanged;content:is inline-only until plan 146's file-based migration follow-up lands. - Fixtures: a runbook-style fixture
exercising
code-block,table,list,paragraph, andunlistedinside one section; bad fixtures for each mismatch diagnostic. - Document
content:in the MDS020 README and docs/guides/schemas.md.
- A schema with
content: [{kind: code-block, lang: yaml}]flags a matched section missing that block. - A schema with
content: [{kind: table, columns: [Setting, Default]}]flags a section whose table has different column headers and passes one with matching headers. - A schema with
content: [{kind: list, ordered: true, min-items: 2}]flags a section with a one-item ordered list. -
closed: trueon the parent scope flags an unlisted AST node inside the section. -
kind: unlistedtolerates unknown nodes at that position even underclosed: true. - Content out-of-order produces the new "out of order" diagnostic naming expected vs actual kind.
- An unknown
kind:value at parse time emits an error naming the scope. -
content:is rejected on a wildcard slot and on aheading: "?"scope until a follow-up plan defines those semantics. - All existing MDS020 fixtures pass unchanged.
- MDS020 README and
docs/guides/schemas.mddocument thecontent:shape with one worked example. - All tests pass:
go test ./... -
go tool golangci-lint runreports no issues.