WORK-294
ID:WORK-294Status:done

Aggregate rune — schema, post-process resolver, tests

Build the core {% aggregate %} rune from SPEC-076 — the third post-process-resolved query rune alongside collection and relationships. One rune, two modes: no-body emits a single integer; body-zoned form (preamble / template / fallback) iterates groups with $item bound differently per zone. Foundation for the rest of SPEC-076 (Lumina styling, plan-progress decomposition, docs).

Priority:highComplexity:moderateMilestone:v0.16.0Source:SPEC-076
claude/v0.16.0 View source

Criteria completion

Criteria completion: 14 of 14 (100%) checked; history from May 28 to May 280%25%50%75%100%May 28May 28
History 2
  1. 9ef3272
    • ☑ `aggregate` schema in `packages/runes/src/tags/aggregate.ts` accepts `type`, `filter`, `value`, `group`, `sort`, `limit`, `empty` attributes; uses `deferBody` to capture the body source.
    • ☑ No-body form (`{% aggregate type=… filter=… /%}`) renders a sentinel that the resolver replaces with a single integer — the count of entities matching the query.
    • ☑ Body-zoned form splits the source on top-level `hr` into preamble / template / fallback zones, reusing `splitBodyZones` from `collection-helpers`.
    • ☑ In the preamble, `$item` is bound to `{ count, value, percent, total }` (with `count === total` in this zone). Without a `value` attribute, `$item.value` falls back to `$item.count` and `$item.percent` is `100`.
    • ☑ In the template, `$item` is bound to `{ key, count, value, percent, total, shown }`; the template is reparsed per group via `transformDeferredTemplate`. `$item.count` is this group's count; `$item.total` is the all-groups total (a constant across iterations).
    • ☑ In the fallback, all numeric fields are `0`.
    • ☑ The `value` attribute is a secondary `field:value` clause (same grammar as `filter`); when set, `$item.value` is the count matching both `filter` and `value`, and `$item.percent` is `(value / count) × 100`, integer 0–100.
    • ☑ `group` is optional — without it, the body renders **once** with the in-context projection (no per-group iteration).
    • ☑ `sort` / `limit` apply over groups; `sort` honors {% ref "SPEC-072" /%} domain-aware ordering when the group field has one.
    • ☑ Self-closing `empty="…"` attribute works as a string fallback; the body's fallback zone wins when both are present (same precedence as `collection`).
    • ☑ A new resolver `resolveAggregates` (parallel to `resolveCollections` / `resolveRelationships`) is wired into `resolveCoreSentinels` and runs at phase 4 with the full registry available.
    • ☑ Engine config entry for `Aggregate` in `packages/runes/src/config.ts` so the BEM block / data-rune is registered.
    • ☑ Catalog entry in `packages/runes/src/index.ts` via `defineRune`.
    • ☑ Tests in `packages/runes/test/aggregate*.test.ts` cover: no-body integer output; preamble / template / fallback zone semantics; `$item.value` / `count` / `total` / `percent` bindings; `value` sub-filter math; `group` ungrouped (single render); `sort` (with and without domain ordering); `limit`; `empty` attribute; mixed types (`type="work,bug"`).
    by bjornolofandersson
  2. 82f6f8a
    Created (ready)by bjornolofandersson

Acceptance Criteria

  • aggregate schema in packages/runes/src/tags/aggregate.ts accepts type, filter, value, group, sort, limit, empty attributes; uses deferBody to capture the body source.
  • No-body form ({% aggregate type=… filter=… /%}) renders a sentinel that the resolver replaces with a single integer — the count of entities matching the query.
  • Body-zoned form splits the source on top-level hr into preamble / template / fallback zones, reusing splitBodyZones from collection-helpers.
  • In the preamble, $item is bound to { count, value, percent, total } (with count === total in this zone). Without a value attribute, $item.value falls back to $item.count and $item.percent is 100.
  • In the template, $item is bound to { key, count, value, percent, total, shown }; the template is reparsed per group via transformDeferredTemplate. $item.count is this group's count; $item.total is the all-groups total (a constant across iterations).
  • In the fallback, all numeric fields are 0.
  • The value attribute is a secondary field:value clause (same grammar as filter); when set, $item.value is the count matching both filter and value, and $item.percent is (value / count) × 100, integer 0–100.
  • group is optional — without it, the body renders once with the in-context projection (no per-group iteration).
  • sort / limit apply over groups; sort honors SPEC-072 domain-aware ordering when the group field has one.
  • Self-closing empty="…" attribute works as a string fallback; the body's fallback zone wins when both are present (same precedence as collection).
  • A new resolver resolveAggregates (parallel to resolveCollections / resolveRelationships) is wired into resolveCoreSentinels and runs at phase 4 with the full registry available.
  • Engine config entry for Aggregate in packages/runes/src/config.ts so the BEM block / data-rune is registered.
  • Catalog entry in packages/runes/src/index.ts via defineRune.
  • Tests in packages/runes/test/aggregate*.test.ts cover: no-body integer output; preamble / template / fallback zone semantics; $item.value / count / total / percent bindings; value sub-filter math; group ungrouped (single render); sort (with and without domain ordering); limit; empty attribute; mixed types (type="work,bug").

Approach

Mirror the existing relationships resolver structure (packages/runes/src/relationships-resolve.ts) — it's the closer cousin since both produce derived output rather than entity items. Query the registry (registry.getAll(type) per type, then apply filter via the existing field-match grammar). When value is set, run the same field-match against the matched set for the achieved subset. Group via groupEntities + domain ordering already in collection-helpers. Per-group iteration uses transformDeferredTemplate(body, embedConfig, { item: groupProjection }) — same path collection's per-item template uses. Preamble likewise but bound once with totals. Reuse splitBodyZones. The single-number form is the no-body short-circuit: skip zone splitting, render a <span class="rf-aggregate" data-aggregate="count">{count}</span> directly.

Dependencies

None — this is the foundation; the other SPEC-076 items depend on it.

References

  • SPEC-076
  • SPEC-070collection, the closest sibling; reuse splitBodyZones, groupEntities, transformDeferredTemplate.
  • SPEC-072 — domain-aware ordering for sort.

Resolution

Completed: 2026-05-28

Branch: claude/v0.16.0

What was done

  • packages/runes/src/tags/aggregate.ts — new schema with type / filter / value / group / sort / limit / empty attributes; deferBody: true. Outer tag is conditional on whether a body was captured: <span> for the no-body inline form so the rune is inline-safe in prose; <section> for the body-zoned form. Emits a sentinel meta plus one meta per attribute.
  • packages/runes/src/aggregate-resolve.ts — new postProcess resolver mirroring relationships-resolve.ts. Reads metas, unions registry.getAll(type) across all listed types, applies filter via the shared parseFieldMatch / matchesFieldMatch grammar, and computes the achieved subset when value is set. No-body form short-circuits to a span carrying data-aggregate="count", data-count="N", and N as the only child. Body form uses splitBodyZones and renders preamble (totals on $item), per-group templates via transformDeferredTemplate with { key, count, value, percent, total, shown } on $item, or a fallback zone with all zeros. sort supports key (domain-ordered when the group field has one) / count / value / percent, with - prefix for descending. limit caps the group set after sorting.
  • packages/runes/src/index.ts — added the aggregate tag import, defineRune catalog entry, and exports for resolveAggregates + AGGREGATE_SENTINEL.
  • packages/runes/src/config.ts — added the Aggregate: { block: 'aggregate' } engine config entry and wired resolveAggregates into the createCorePipelineHooks postProcess chain immediately after resolveRelationships (same placement rationale: after expand, before xref).
  • packages/runes/test/aggregate.test.ts — 15 tests covering all acceptance criteria: no-body inline integer, mixed types, preamble totals binding, fallback to count when no value attribute, per-group template bindings, fallback zone zeros, ungrouped single render, value sub-filter math (achieved subset over primary set), limit caps + shown reflects post-limit count, sort by count, SPEC-072 domain-aware ordering via orderings override, empty-attribute string fallback, body-fallback precedence over empty-attribute, and outer-wrapper shape per mode.
  • packages/lumina/test/css-coverage.test.ts — added aggregate to UNSTYLED_BLOCKS with a note that the CSS lands in WORK-295.
  • contracts/structures.json + packages/lumina/contracts/structures.json — regenerated to include the new Aggregate rune.

Notes

  • Implementation reuses the existing splitBodyZones, groupEntities, transformDeferredTemplate, buildOrdering, and field-match grammar from collection-helpers and field-match. No new infrastructure was needed — the rune slots into the same patterns collection/relationships already established.
  • The "wired into resolveCoreSentinels" criterion is satisfied via the createCorePipelineHooks postProcess chain, where resolveCollections and resolveRelationships already live. The standalone resolveCoreSentinels export covers a different set (breadcrumbs / nav / pagination / blog / xref), and that hasn't changed.
  • Per-group wrapper divs carry data-block="" so first/last-child margin-trimming targets them the same way collection's per-item wrappers are targeted.
  • Tests use a small textOf() helper that flattens string + number children — Markdoc inserts the bound numbers as number values, so JSON.stringify shows them as "value=",4 rather than "value=4". The helper recombines them so assertions can target the rendered text.