| id | 166 | |
|---|---|---|
| title | Schema-driven data extraction (mdsmith extract) | |
| status | 🔲 | |
| model | opus | |
| depends-on |
|
|
| summary | Derive a default data tree from the hierarchical schema and add an `extract` subcommand that emits a kind-conformant file as JSON/YAML/msgpack. |
Let a kind's schema double as an extraction contract.
Once mdsmith check confirms a file conforms, mdsmith extract <kind> --format json|yaml|msgpack <file> emits a
data tree. Its shape is derived from the schema hierarchy
itself — no annotations required.
The schema is already a hierarchy: front matter, then a tree of scopes (sections), each with child scopes and content entries. That hierarchy is the data shape. So the first deliverable is a default binding layer that projects the schema tree into a data tree directly, mirroring its nesting. No new schema concept is needed for the common case.
Custom shaping is not in this plan. It is a separate
follow-up — plan 167 —
and we keep it cheap by design: every key flows through
one keyFor(node) seam (task 3), so the override plan is
a focused change there plus parsing bind:. Until then,
renaming or restructuring is the job of a downstream tool
(jq, yq) over the standard-format output.
The projection walks the composed schema in lockstep with the validated match and mirrors the hierarchy:
- Root shape. The root object holds a
frontmatterobject (the decoded front matter, unchanged) and the projected sections beside it at the same level. Front matter stays grouped so it never collides with a section slug. - Literal-heading scope (
## Goal) → object keyed by the slugified heading (goal), reusing the existing anchor slugifier. Its value holds child scopes and content, recursively. - Repeating scope (
## {id}with arepeat: {min, max}cardinality) → an array keyed by the slug of the heading's literal stem, or the placeholder name if the heading is only a placeholder. Each element is an object that always retains every captured placeholder as aname: valuefield (both the placeholder name and its value survive), plus the element's own child scopes and content. - No-heading section (
heading: null— content before the first child heading) has no heading text and therefore no slug. Its content entries project directly into the enclosing object (root, or the parent section) beside the headed-section keys — there is nopreamblewrapper key. Wildcard slots (regex: '.+') and unlisted/closed headings are skipped: the output is a faithful projection of the declared schema only. code-block→ string undercode(raw body); multiple blocks getcode,code-2, …list→ array of item strings underitems.tablewithcolumns→ array of row objects keyed by column header, underrows.paragraph→ its text undertext.
Sibling key collisions (two ## Goal headings, or a
content default that shadows a child scope slug) are a
schema error reported at extract time, pointing at the
schema source. Empty/optional sections that did not match
are omitted rather than emitted as null.
This plan consumes the reworked schema engine, not the legacy single-source model.
- Entry-shape unification (
156_schema-entry-unification/ PR #295) — landed in main. Everysections:entry is discriminated by itsheading:value: a string or{regex, repeat?, sequential?}mapping for headed sections, andheading: nullfor the no-heading section (content before the first child heading). There is no standalonepreamble:key. The projection rules above target this shape directly. - Plan 156 — kind-schema
composition / PR
#288. (Two plan files share id 156, so this
dependency is named by filename here rather than in
numeric
depends-on:: it is the composition one, not the now-landed156_schema-entry-unification.) A file can resolve to multiple kinds whose schemas compose viaschema.Compose(). The extractor consumes the composedSchema. Default keys derive from heading text, so identical headings from two kinds merge to the same key with no conflict; only genuinely divergent shapes surface as a collision. - Plan 149 (section-content schema). Content
projection rides on the
ContentEntrymodel from the content-schema work. This plan adds no content matcher of its own and is blocked until that model is stable. - Plan 147 / PR #284 (actionable schema diagnostics).
If landed, collision and conformance failures reuse the
SchemaDiagnosticformatter.
Extraction is gated on a successful schema match. A
non-conformant file makes extract report the same
diagnostics as check and exit non-zero. It never emits
partial data.
- Expose the match tree. Refactor
schema.Validate(and the content matcher) to also return a new*schema.MatchTreeininternal/schema: for eachScope/ContentEntry, the matched AST nodes, their source lines, and captured{field}values.Validatekeeps its diagnostic return; the tree is an added result so MDS020 is unaffected. Unit-test the tree on the existing schema fixtures. - Extractor skeleton (red/green). Add
internal/extractwithExtract(f *lint.File, sch *schema.Schema, m *schema.MatchTree) (any, []lint.Diagnostic).schis the composed schema;mis the tree from task 1 — no re-matching. - Default scope projection. Walk the scope tree and
build the nested structure per the rules above:
frontmatterplus sections at the root, literal scopes keyed by slug, theheading: nullno-heading section's content hoisted into the enclosing object, wildcard / unlisted skipped. Route every key through onekeyFor(node)function — the single seam a future custom-binding plan overrides. Reuse the existing anchor slugifier. Unit-test literal, nested, no-heading-section, and optional-omitted scopes. - Repeating scopes and placeholders. Project scopes
with a
repeat: {min, max}cardinality as arrays; each element retains every captured{field}as aname: valuefield, reusing fieldinterp. - Default content projection. Project
code-block,list,table, andparagraphentries (plan 149) with their default keys. Detect sibling key collisions and emit a schema diagnostic. - Composition behavior. Add
compose_test.go/ extractor tests proving a file under two kinds yields a merged tree, and that a real shape divergence is reported as a collision, not silently dropped. - Format encoders. Add
internal/extract/encodewith json (stdlib), yaml (existing dep), and msgpack encoders behind aFormatenum. (Lua is deferred.) extractsubcommand. Registerextractin main.go; signaturemdsmith extract <kind> --format <fmt> <file>. Reuse the config-load and kind-resolution helpers from kinds.go. Validate that<kind>is one of the file's resolved kinds. Run schema validation first and abort on failure.- Fixtures and integration test. Add a kind with a
schema under
testdata/, a conformant sample, and golden outputs per format. Assert non-conformant input exits non-zero with check diagnostics. - Docs. Add a section under
schemas.md and a
docs/reference/cli/extract.mdpage. Both are picked up by existing catalog directives. Runmdsmith fixso catalogs and PLAN.md regenerate.
-
mdsmith extract <kind> --format json <file>on a conformant file emits a tree whose nesting mirrors the schema hierarchy — no schema annotations required. - The root holds a
frontmatterobject and the projected sections beside it at the same level. - Literal headings key by slug; repeating sections
become arrays; each element retains every captured
placeholder as a
name: valuefield plus its child scopes/content. - A
heading: nullno-heading section's content projects into its enclosing object (nopreamblewrapper key); wildcard and unlisted/closed headings are skipped. - Code-block, list, table, and paragraph entries project under their default keys; sibling key collisions are reported as schema diagnostics.
- A file resolving to multiple kinds yields a merged tree; a genuine shape divergence is reported, not silently dropped.
-
json,yaml, andmsgpackproduce equivalent data; golden fixtures cover all three formats. - A non-conformant file makes
extractexit non-zero and print the same diagnostics asmdsmith check. - An unknown kind, or a kind not assigned to the file, exits non-zero with a clear message.
- All tests pass:
go test ./... -
go tool golangci-lint runreports no issues -
mdsmith check .passes
- Repeating-scope key. Array key is the slug of the
heading's literal stem, or the placeholder name when
the heading is only a placeholder. Each element always
retains every captured placeholder as a
name: valuefield, so both the name and the value survive. - Front matter placement. The root holds a
frontmatterobject and the projected sections beside it at the same level. Grouping front matter avoids collisions with section slugs. - No-heading section. A
heading: nullentry has no slug; its content projects directly into the enclosing object rather than under apreamblewrapper key. The sibling-collision rule covers any clash with a section slug. Wildcard slots and unlisted/closed headings are skipped. - Lua deferred. Ship json, yaml, and msgpack. A Lua
encoder can be added later behind the same
Formatenum. - Custom bindings ship in plan
167, layered on the
keyForseam; out of scope here. - LSP /
query-style selector for extraction is out of scope here.