SPEC-142
ID:SPEC-142Status:draft

Infer the transform signature from the rune's own declarations

transform(resolved, attrs, config) is the one place a rune does real work, and both of its interesting parameters are untyped:

claude/nifty-ptolemy-80epot View source
Branches 1
claude/nifty-ptolemy-80epot current draft
main draft
History 3
  1. 9762a80
    Content editedby bjornolofandersson
  2. 98d822d
    Content editedby bjornolofandersson
  3. f1d50db
    Created (draft)by bjornolofandersson
transform: (resolved: ResolvedContent, attrs: Record<string, any>, …)

attrs.nmae is not merely unchecked — it is any, so it poisons everything downstream. ResolvedContent is { [fieldName: string]: ResolvedField } where ResolvedField unions with unknown and collapses to it, so resolved.anythingWhatsoever is legal on all 121 runes.

Nothing new needs to be declared. required: true, matches: [...], name: 'body', greedy: true, optional: true are already written in every rune. ContentModelSchemaOptions widens them away. Make the builder generic with const type parameters (TS 5.0+; the repo is on 5.9.3) and the types fall out of declarations that already exist.

Prototyped and measured in spike/rune-typing/ — machinery, compiling cases, self-asserting negatives, and a compile-time benchmark. node check.mjs.

Problem

Four costs, in rough order of how much they hurt:

Typos are silent. A misspelled attribute or resolved field is any / unknown, never an error.

Shape assumptions are invisible. resolveSections returns two structurally different things from one model type — Ast.Node[] when emitTag is set (resolver.ts:502), and resolved entries carrying $heading, $canonicalName and the recursed body when it is not (:536). The runes split 6/8 on this. Nothing in the types says which you have.

Consumers re-assert by hand what the model already declares:

// plugins/plan/src/util.ts:27
export function buildSections(sections: any[], config: any): any[] {
	const headingText = section.$heading as string;
	const canonicalName = section.$canonicalName as string | undefined;
	const canonicalSlug = section.$canonicalSlug as string | undefined;

Enumerations decay to string. $canonicalName is one of the rune's declared known-section names or undefined. Read as string | undefined, a comparison against a typo — or against a section name another rune declares — is a silent no-op.

What the spike settled

CaseVerdict
Attributes: required/optional, matches → union, base merge, the .slice() idiominfers
sequence: field names, greedy → array, optional → | undefinedinfers
sections with emitTag → Node[]infers
sections without emitTag → entries, recursing into sectionModelinfers
delimited + dynamicZones → recursing into zoneModelinfers
knownSections → $canonicalName as a literal unioninfers
knownSections with a per-section model:infers; message is bad
custom, headingExtractopaque — unchanged from today

The worry going in was that content models would be hard: four kinds, and they nest. Both halves were wrong. Recursion costs nothing structurally — conditional types recurse — and the variety maps to a conditional chain. The difficulty is one optional field that changes a return shape (emitTag) and one override nobody uses (model:).

Decisions

D1 — generics with defaults, not decorators

Decorators or a model class would move the declaration out of plain-object land. refrakt inspect, refrakt contracts, the rune catalog and the attribute reference all read these schemas as runtime data; decorator metadata is harder to reflect over and serialise than an object literal. The plain-object form is load-bearing, and the information is already in it.

D2 — the generic defaults must be Record<never, never>

Not Record<string, never>, which carries a string index signature: with it, any rune omitting base/attributes type-checks every attribute access and the feature silently does nothing. The spike caught this mid-flight — three expectations stopped firing while the resolved ones kept working, which is exactly how this would ship looking like a success. A regression test covers it.

D3 — adoption is incremental, one plugin at a time

With D2's defaults, an untouched rune keeps today's loose types. No cutover, no flag day. Start with plugins/learning (2 runes, both plain sequence), then re-measure before going wider.

D4 — the 12 thunked content models gain as const; nothing else changes

const type parameters do not reach through a function's return type, so contentModel: () => ({…}) widens optional: true to boolean and loses field-level inference. One trailing as const fixes it, replacing the two or more inner as consts those files already carry. The other 106 content models need no call-site change at all.

D5 — custom and headingExtract stay opaque

processChildren is an arbitrary function (14 runes) and headingExtract derives keys from a runtime pattern. Both keep today's loose type. That is not a regression, and template-literal gymnastics for headingExtract would not pay for themselves.

D6 — KnownSectionDefinition.model is decided separately: use it or lose it

model lets a known section resolve its body against a different content model than its siblings (resolver.ts:518). No rune declares one, and it was not among the three purposes WORK-024 added knownSections for — validation, aliases and editor templates. It arrived with the type.

It is also the only place the inferred type is unpleasant rather than merely verbose: the error inlines the whole model, truncated, and neither a named SectionEntry<M> alias nor hoisting the model to a named const improves it (both tried in the spike).

But there is a real latent use, so this spec does not propose removing it. work's Acceptance Criteria is semantically a checklist, and the plan pipeline currently counts it by regex over rendered text:

// plugins/plan/src/pipeline.ts
const unchecked = (text.match(/\[ \]/g) || []).length;
const checked = (text.match(/\[x\]/gi) || []).length;

A literal [x] anywhere in a work item inflates that count. A per-section model resolving criteria as a structured list is the right fix, and it is exactly what this field is for.

So the decision is use it or lose it, and it belongs to the plan runes, not to this spec. What it must not do is stay unused and impose the worst error messages in the scheme. Either outcome is compatible with the types here: the union is correct when a model exists and collapses to a single shape when none does.

D7 — the benchmark is re-run against the real tree before the last plugin lands

The spike's +20–28% is 121 runes in the heaviest shape, against a synthetic file. The real mix is 87 sequence, 13 sections, 3 with knownSections, so the true figure should be lower — but this is a library whose types consumers' builds instantiate too, and a synthetic benchmark is not evidence about their builds.

Non-goals

  • Changing the authoring format. No decorators, no model class, no new fields
  • Changing any rune's runtime behaviour, output, or the resolver
  • Typing config (the Markdoc Config) or the node parameter
  • Making inference mandatory — D3 requires the loose path keep working
  • Inferring custom models (D5)

Acceptance Criteria

  • createContentModelSchema is generic over base, attributes and contentModel, with const type parameters
  • attrs resolves required attributes as present, optional as | undefined, matches as a literal union, and String/Number/Boolean as their primitives
  • resolved resolves sequence field names, with greedy as an array and optional as | undefined
  • resolved.sections is Node[] when emitTag is declared and resolved entries when it is not
  • A section entry's body type recurses through sectionModel, and a delimited zone's through zoneModel
  • $canonicalName is the literal union of the rune's declared known-section names
  • The generic defaults are Record<never, never>, with a regression test that a rune declaring no attributes still rejects an unknown one (D2)
  • A rune that has not been migrated compiles unchanged against the loose types (D3)
  • The 12 thunked content models carry as const on the returned object; the other 106 are untouched (D4)
  • custom models resolve to the loose type, with no error (D5)
  • spike/rune-typing/check.mjs passes against the shipped types, not just the prototype
  • buildSections (plugins/plan/src/util.ts:27) drops its any[] parameter and its six hand-written casts
  • Compile time is measured on the real tree before the final plugin migrates, and recorded (D7)
  • The rune authoring guide documents what is inferred, what stays opaque, and the as const rule for thunked models

References

  • spike/rune-typing/ — the prototype, cases, negatives and benchmark this spec rests on
  • SPEC-140 — the transform boilerplate survey; same surface, independent change
  • SPEC-125 — the join tables a rune declares about itself; the same "the rune already said this" principle
  • ADR-008 — the flat namespace properties and refs share, which the inferred types must keep enforcing