WORK-603
ID:WORK-603Status:done

Add fieldMetas and groupByHeading

The two utilities with actual design in them, as opposed to the spellings in WORK-602.

fieldMetas(attrs, spec) collapses the declare-then-name-again cycle into one declaration, returning a Record<string, Tag> usable directly as properties. plugins/plan/src/tags/work.ts goes from 36 lines of plumbing to about 13:

Priority:mediumComplexity:moderateMilestone:v0.38.0Source:SPEC-140

Criteria completion

Criteria completion: 10 of 11 (91%) checked; tracking started on Sep 24, no incremental history yet0%25%50%75%100%Sep 24Oct 11

Tracking started Sep 24 — check back for trends.

Acceptance Criteria

  • fieldMetas' spec is data: a bare string, or { from: [...], default } — no entry accepts a function
  • The spec round-trips through JSON.parse(JSON.stringify(...)) unchanged
  • from resolves only the declared roots (attrs.*, file.*); an unknown root is rejected at call time rather than resolving to empty
  • The five runes reading config.variables.file express their created / modified fallback without a closure
  • A property-and-ref name collision is still rejected with the ADR-008 error when the properties object comes from fieldMetas
  • fieldMetas is adopted where a rune's metas are all plain attrs reads mapped into properties; runes needing a meta outside properties, or conditionally, keep the explicit form
  • groupByHeading is adopted at the seven loop sites, with each rune's per-item parser left rune-specific
  • No lint rule or contract assertion makes either utility mandatory
  • The utility is named fieldMetas; nothing in the codebase or docs introduces a second metaFields, and RuneConfig.metaFields is unchanged
  • refrakt contracts --check and npm run seo:baseline:check report no drift
  • npm test passes unchanged

Approach

fieldMetas writes into the same flat key space as refs (ADR-008), so the collision check has to keep working against a computed object rather than a literal — worth a test, since the current check reads Object.keys of both and a generated object is the case nobody has exercised.

Key order matters for data-rune-fields, which is JSON.stringifyd: iterate the spec in declaration order so the bag's key order stays stable and the contracts diff stays empty.

groupByHeading shares only the traversal. parseColorEntry, parseNameValue, parseFontEntry and parseLocationItem stay where they are — the loop is the duplication, not the parsing.

Blocked by

  • WORK-598 — fieldMetas produces the properties object, so it lands after the children emission is gone rather than having to reproduce it

References

  • SPEC-140 — Tier 3, D5
  • ADR-008 — the flat namespace properties and refs share

Resolution

Completed: 2026-10-06

Branch: claude/v0-37-post-release-plan-dc3va1 PR: refrakt-md/refrakt#657

What was done

  • packages/runes/src/lib/field-metas.ts: fieldMetas(attrs, config, spec) — spec is data (bare-string default, or { from: ['attrs.x' | 'file.x', …], default }); every source is validated before one is chosen, so an unknown root throws even after a hit; keys emerge in declaration order. Exported with FieldMetaSpec / FieldMetaEntry / FieldMetaSource.
  • Adopted by the five plan runes (work, bug, decision, milestone, spec — the fileVars closures are gone) and the seven other calls whose properties are all plain attrs.X ?? 'literal' reads: sidenote, pullquote, map-pin (gained its config param), event, organization, comparison-row, api.
  • packages/runes/src/lib/node.ts: groupByHeading(nodes, { initial, heading, item, other? }) — shares only the traversal; the rune's heading callback decides the next group. Adopted at tint (unrecognised heading keeps the section), palette ×2 (same-titled headings stay separate groups), spacing ×2 (unrecognised heading closes the section; one shared sectionOf), map (non-list nodes pass through in order).
  • packages/runes/test/field-metas.test.ts: JSON round-trip, root rejection (incl. a bad source after a hit), declaration order, ADR-008 collision on a computed properties object, traversal incl. other.

Notes

  • Bento is left explicit, by decision on #658. Its loop collects the sibling nodes under each heading and never looks at list items — a different walk, and fitting it would grow the helper for one caller. Hence the unchecked "seven loop sites" criterion: six adopted.
  • Runes whose property keys differ from their attribute names (hint's hintType ← type) or whose metas are conditional keep the explicit form; neither utility is mandatory.
  • refrakt inspect --json over every rune × variant on both sites was byte-identical to main.