Skip to content

ADR-0027: Roadmap Graph Rendering (read-only, Mermaid + DOT)

  • Status: Accepted. Implemented in lib/roadmap-graph.cjs. Read-only: no verb writes roadmap.yaml.
  • Date: 2026-07-30
  • Supersedes: None
  • Related: ADR-0006 (depends_on lives in the YAML this renders), ADR-0009 (the earlier "do we adopt a UI framework" decision, and its answer), ADR-0028 (the conditional edges this draws)

Context and Problem Statement

roadmap.yaml already encodes a dependency graph. Milestones carry depends_on (ADR-0006), tasks carry their own depends_on in the plan frontmatter, and slices run as serial waves inside a milestone.

Every existing view of it is a list:

  • roadmap-render produces a Markdown table.
  • dashboard produces a status block.
  • stats produces counters.

A reader who wants to know "what unblocks when this slice lands" has to reconstruct the edges by hand from ids. Worse, the serial slice ordering that the executor actually enforces appears in roadmap.yaml only as list order — it is implied, never stated, and a reader has no way to distinguish "S002 happens to be listed second" from "S002 waits for S001".

ADR-0009 already settled the adjacent question — no TUI framework, no dependency, compose the existing CLI instead — so the bar here is a solution that adds a view without adding a surface.

Decision Drivers

  • The edges are the whole value. A prettier list is not an improvement.
  • [ADR-0002] Zero runtime dependencies, and no bundled renderer.
  • The plan's source of truth must stay a reviewable text file under git. This is the constraint that decides the write question below.
  • A generated artefact must not become a second source of truth.
  • A defect in the data must not be hidden by the drawing.

Considered Options

  • A: A visual drag-and-drop roadmap editor. The shape the no-code agent builders take. Rejected — see below.
  • B: Render to an image (SVG/PNG) with a bundled layout engine. Rejected: a layout engine is a dependency, and a binary artefact is not diffable.
  • C: Extend roadmap-render's Markdown table with a dependency column. Cheap, but a column of ids is the same reconstruction work moved one step closer.
  • D: Render text in formats the reader already has a viewer for — Mermaid and Graphviz DOT. Chosen.

Decision Outcome

Chosen: Option D, read-only, two formats:

  • Mermaid renders inline in the VitePress docs and in most Markdown previewers, so the diagram can be published with --markdown --out and needs no tooling to read.
  • Graphviz DOT for anyone who wants layout control or a rendered image, using software they already have rather than software nubos-pilot ships.

Why read-only, explicitly

The lesson taken from the visual-builder tools is narrow: a diagram is a good view of a plan; it is not a good editor for one. The plan's source of truth is a reviewable text file under git, and a drag-and-drop surface that writes back to it trades that for an artefact nobody can diff and no critic can lint. Option A would have inverted the property that makes the plan trustworthy.

So there is no write path, and the generated block carries a do-not-edit header. Regenerate rather than edit: the diagram is a projection, and a hand-edited projection is a second source of truth that will disagree with the first.

What the edge styles encode

EdgeMeaning
bold ==>Wave boundary. Slices run serially; tasks inside one slice run in parallel.
thin -->A declared depends_on, milestone-level or task-level.
dotted, labelledA conditional slice edge (ADR-0028), labelled if <status> / unless <status>.

The wave edge is the reason this ADR exists. It is the ordering the executor enforces and the one thing roadmap.yaml does not state, so making it visible is the largest single gain. A thin edge inside a slice is then immediately readable as "this wave is not actually parallel".

Colour collapses status into five buckets (done, active, pending, skipped, failed). The diagram answers "what is blocked", not "what is the exact enum value" — roadmap-render already prints the latter.

A dangling dependency is reported, not drawn

A depends_on naming a milestone nothing declares is printed to stderr and not drawn. Both halves matter: the edge cannot be drawn because one endpoint does not exist, and drawing nothing without saying so would leave a diagram that reads as complete while hiding a defect in roadmap.yaml.

The render still exits zero. A roadmap defect is a finding, not a reason to refuse the view that surfaced it.

Consequences

Good, because:

  • The implied wave ordering becomes explicit for the first time.
  • Publishing into the docs is one command, and the output is diffable text.
  • No dependency, no daemon, no new UI surface — consistent with ADR-0009's answer.

Bad, because:

  • Mermaid's layout is not controllable. A large milestone renders as a tall diagram and the reader is expected to reach for --milestone N or --level slice.
  • Two output formats is two renderers to keep in agreement; the tests assert node-declaration parity across both because that is where they can silently diverge.
  • Labels are truncated for readability, so a long slice name is not fully visible in the diagram.

Two defects found while writing the tests

Both would have shipped as plausible-looking diagrams, which is why they are recorded here:

  1. A slice with tasks became a subgraph and its node was never declared. It was marked as emitted while only the subgraph was written, so every wave edge pointed at a node the diagram does not define — Mermaid invents an unstyled phantom box and DOT strands a node outside its cluster. The slice node is now declared inside its own cluster as the wave anchor. The first Mermaid test missed this because it checked text.includes(id) and the id also occurs inside subgraph cluster_<id>; the test now matches the declaration form.
  2. Task depends_on names siblings by local id (T0002) while node ids are full ids (M001-S001-T0002). Indexing on one spelling resolved no edges at all and rendered a wave that looked fully parallel when it was not. Both spellings are indexed now.

More Information

  • Implementation: lib/roadmap-graph.cjs, CLI bin/np-tools/roadmap-graph.cjs, workflow workflows/roadmap-graph.md.
  • Hierarchy read: lib/planning-tree.cjs, shared with the OTLP exporter (ADR-0026). Extracted rather than copied — a second hand-rolled walk would drift the first time the layout changed, and the two views would then disagree about the same project.