Skip to content
SQA Cockpit

ADR-0016 — Pretty-mode summary block; structured envelope is the source of truth

ADRsUpdated 5 min readEdit on GitHub ↗

ADR-0016 — Pretty-mode summary block; structured envelope is the source of truth #

  • Status: Accepted
  • Date: 2026-05-07
  • Deciders: Natan

Context #

A run produces:

  • A pino-pretty-rendered stream of step lines as the run progresses

(one line per step() ok/error).

  • A final structured Result tree in memory carrying every leaf's

outcome, reason, observed timing, and trace context.

For a human running make run interactively, this wasn't enough. The step lines are useful while the run is in flight, but at the end the user wants one quick read of what passed, what didn't, what to fix first. Without a summary they had to scroll up, mentally re-aggregate eight outcomes, and read free-form log strings to extract reasons. That's friction at the moment of greatest frustration (a run just failed; what now?).

The earlier shape dumped the entire Result tree as a final pino info line — readable but noisy. Two problems with that:

  1. JSON-as-output isn't what a human wants from make run.
  2. The summary needs to be a consolidation of the run, not a

reproduction of it.

Decision #

In pretty mode (LOG_FORMAT=pretty, or auto with a TTY), the runner emits a summary block at the end of the run. The block is the only end-of-run artifact in pretty mode — there is no parallel JSON dump.

In JSON mode (LOG_FORMAT=json, or auto without a TTY — pipes, CI, cron), the runner emits a structured "done" log line carrying the full Result tree. There is no summary block.

The two modes are mutually exclusive — never both. The runner picks based on LOG_FORMAT and process.stdout.isTTY, and emits exactly one terminal artifact.

Layout (pretty mode) #

text
══════════════════════════════════════════════════════════════════════════════
 SQA RUN SUMMARY
 trace: <traceId>   trigger: <kind> (<user>)   duration: <total>
──────────────────────────────────────────────────────────────────────────────
  <glyph> <outcome>  <stepId>  <name>           <duration>   <reason>
  ...
──────────────────────────────────────────────────────────────────────────────
 RESULT:   <glyph> <OUTCOME> — N of M probes did not pass (counts...)
 HEADLINE: <name> — <reason>
 EXIT:     <0|1>
══════════════════════════════════════════════════════════════════════════════
  • Per-probe row for every leaf in declaration order. Reads

top-to-bottom like a checklist.

  • Glyphs colour-mapped to outcome ( green pass, yellow

warn, red fail/error, dim skip). The glyph alone never distinguishes fail from error — the body of the row carries that distinction (the reason string starts with the operative verb), so the at-a-glance scan is binary (pass or not), and the detail is one column to the right.

  • Counts in the RESULT line: `pass=N, warn=N, fail=N, error=N,

skip=N`. At-a-glance density.

  • HEADLINE picks the first non-pass leaf in declaration order,

matching the rule aggregate() already uses for the parent Result's reason. One source of truth for "what should I fix first."

  • EXIT explicit so the user knows what cron / CI sees without

inferring it from outcome.

Source of truth, view, and report #

  • The Result tree in memory is the source of truth.
  • The summary block is a rendered view of the tree for humans.

No new state, no new fields; everything in the summary is read off the tree.

  • A future report (PRD candidate) will be a third rendered view —

long-form JSON exported to a file (or S3) for replay, dashboards, and historical analysis. That report is out of scope for the console output.

This split is deliberate: console output is for the moment of the run; reports are for after. Mixing them produces a console that's too verbose for "did it work?" and a report that's too transient for "what happened last Tuesday?".

Implementation #

src/lib/render.ts exports renderSummary({ result, traceId, trigger, totalDurationMs }) which walks the Result tree, flattens to leaves in declaration order, and returns the rendered string. No side effects; the caller decides where to write.

src/index.ts emits the summary via console.log (synchronous, ordering-stable) after runLogger.flush(callback) drains the async pino-pretty transport. Direct process.stdout.write would race the transport and land the summary in the middle of the step lines.

ANSI colour codes are emitted only when the output is a TTY and NO_COLOR / FORCE_COLOR=0 aren't set. Non-TTY pretty mode (rare — it requires explicit LOG_FORMAT=pretty) emits the same layout without colour codes, so piping through tee keeps the structure.

Consequences #

  • Pro: A human running make run sees one consolidated artifact

at the end. Pass/fail/error/warn/skip per probe, headline, exit code — all in ~12 lines.

  • Pro: The summary is a view, not a duplicate. Adding a new

outcome or context field updates one render function; the run semantics are untouched.

  • Pro: JSON mode (CI, cron) still gets the full Result tree as

one structured terminal record. No mode is silent.

  • Pro: The colour and glyph language matches pino-pretty's

level colours (green/yellow/red), so the summary visually belongs to the same run.

  • Con: Two output modes (pretty / JSON) means a contributor

testing locally sees a different terminal artifact than CI does. Mitigated by: (a) the underlying Result tree is identical, (b) the pretty summary's content is a strict subset of the JSON envelope's, (c) LOG_FORMAT=json bun run src/index.ts lets a contributor reproduce CI-mode output locally for a sanity check.

  • Con: ANSI codes leak into log files if the user redirects a

TTY-attached run to a file. Mitigated by NO_COLOR=1. We document this in the README "Configuration" table.

  • Falsifiability: Revisit if (a) the per-probe row exceeds 80

columns to the point of wrapping (truncate the reason, not the name); or (b) someone wants both the summary and the JSON envelope simultaneously, which means we should expose a --report=path flag and write the JSON to a file instead of duplicating it on stdout (that's the report PRD).

See also #

output mode and emits the summary.

envelope (the data the renderer consumes).

timing (the duration column).

(the header line).

(declaration order in the summary, not execution order).

Was this page helpful?