Writing reports

VS Code CLI Beta

A report is a single, self-contained HTML document, rendered from a user-authored Jinja template against your project’s needs index. Because the output has no external dependencies, it can be opened in any browser, attached to a release, archived for an audit, or sent by email.

Templates are .html.j2 files that live in your repository, in the directory configured by [reports].directory in ubproject.toml (default: reports/). A template can also be declared as an insight: its Cypher queries run before it, and it can be rendered for a need, in VS Code from that need’s CodeLens.

Quickstart

ubc quickstart scaffolds a runnable starter template at reports/needs_overview.html.j2, including a table of all needs and a pure-SVG bar chart of needs per type. Render it with:

ubc report needs_overview.html.j2

The rendered HTML is written to <reports-dir>/_build/needs_overview.html by default; use -o/--outpath to write elsewhere. To list the templates available in your project:

ubc report --list

Templates may also live in subdirectories of the reports directory: --list shows them as paths relative to that directory (e.g. insights/status.html.j2), which is the name to pass when rendering, and the default output keeps the subdirectory under _build/ (so two templates with the same file name never collide).

Template context

Templates are rendered with minijinja and receive two variables and two functions:

  • needs — a dictionary of all needs, keyed by need ID.

  • needs_list — the same needs as a sequence, sorted by ID.

  • need_url(id) — a link to a need, for an href (see Linking a need).

  • theme_style() — the --ub-* design tokens, for a <style> element (see Theme tokens: theme_style()).

Each need exposes id, type, title, and every other field defined for it (e.g. status, tags, custom options). Missing fields render as empty strings rather than raising errors.

Every need also exposes the context fields — the same names a :filter: expression and a needtable :columns: entry resolve, answered from the need itself rather than from the project’s field schema:

Name

Value

content

The need’s body text (empty when it captured none).

docname

The document the need belongs to, without its extension. Empty for an external need.

lineno

The 1-based line its directive starts on. Empty for an external need.

sections

The titles of the sections enclosing the need, innermost first (a list).

section_name

The innermost of those titles, or empty when the need is under no section.

is_external, is_directive, is_import, is_src_trace

Where the need came from.

is_modified

Whether the need was modified — by a needextend or by variant resolution.

modifications

How many needextend directives matched the need.

is_need, is_part

Always True and False — a report renders needs, not need parts.

id_parent, id_complete

The need’s own id, as in sphinx-needs.

If your project declares a field with one of these names, your field wins — except for id, type and content, which are the need’s own identity and cannot be shadowed. That is the same rule the :filter: surface applies to the same project.

{% for n in needs_list %}
<h3>{{ n.id }} — {{ n.section_name }}</h3>
<p><a href="{{ n.docname }}.html#{{ n.id }}">{{ n.title }}</a></p>
{% endfor %}

There are deliberately no pre-computed groupings: use standard Jinja filters to build any view you need. For example, to group needs by type:

{% for type, group in needs_list|groupby('type') %}
<h2>{{ type }} ({{ group|length }})</h2>
<ul>
  {% for n in group %}
  <li><code>{{ n.id }}</code> {{ n.title }}</li>
  {% endfor %}
</ul>
{% endfor %}

groupby works for any field (status, tags, custom options, …), and combines with selectattr / rejectattr for filtered views.

Linking a need

need_url(id) returns a link to the need with that ID, to put in an href:

{% for n in needs_list %}
<a href="{{ need_url(n.id) }}">{{ n.id }}</a>
{% endfor %}

What the link points at depends on where the page is shown:

Need

ubc report file and Open in Browser

Written in a document (.. req::, a needimport, a test report)

The document, at the directive’s line, relative to the written page: ../../docs/page.rst#L308 for a page in reports/_build/

Traced from source code (src-trace)

The source file, at the marker’s line

External (needs_external_needs)

Its external_url when that is an http(s) URL, else the local JSON file it was read from (no line)

Anything else (no known location)

#

For a need written in a document or in code, that is the location Go to Definition jumps to on the need’s ID. The link is relative to the directory the page is written to, so the page holds no absolute path of your machine and keeps working when the project folder is moved or shared together with the page; only a source on another drive (or network share) than the page gets an absolute file:// URL. A source in the page’s own folder links as ./page.rst#L5. Browsers open the file but ignore the #L<line> part. In the VS Code preview panel the same link renders as #ubcode-need:<ID> (the ID percent-encoded), and clicking it reveals the need’s source in the editor, beside the panel — the same location the need’s go to source action opens anywhere else in the extension (an external need with a URL opens in the browser, a need with several locations asks which one). The preview stays script-free: the click is handled by the panel itself, not by the template.

An ID that names no need links # on every host, as a missing field renders empty; so does none or an undefined value (need_url(r.missing)). An argument that is not a string — a number, or a whole need instead of its id — fails the render. The returned link is already HTML-escaped (and marked safe), so it is never escaped twice.

Charts

Reports stay self-contained by using inline SVG for charts, generated directly by the template — no JavaScript required. The quickstart template demonstrates the pattern:

{% set groups = needs_list|groupby('type') %}
{% set max_count = groups|map('last')|map('length')|max %}
<svg viewBox="0 0 640 {{ groups|length * 32 + 4 }}" role="img">
  {% for type, group in groups %}
  <g transform="translate(0, {{ loop.index0 * 32 }})">
    <text x="0" y="19" font-size="13">{{ type }}</text>
    <rect x="110" y="4" height="22" rx="3" style="fill: var(--ub-accent)"
          width="{{ (group|length / max_count * 480)|round(1) }}"></rect>
  </g>
  {% endfor %}
</svg>

The var(--ub-accent) fill is one of the theme_style() tokens the template declares in its <style>: it follows the editor’s theme when previewed in VS Code and the browser’s light or dark mode everywhere else (see Matching the editor theme below).

In VS Code

The ubCode extension brings the same renderer into the editor. The Reports view in the ubCode sidebar lists the project’s templates (the same names as ubc report --list, including subdirectories), following the active editor or the pinned project (see the ubcode.views.pinnedProject setting). The view refreshes automatically when ubproject.toml changes, so a newly configured [reports].directory is picked up without a reload.

From the view you can:

  • Generate & Preview (click a template, or its inline play button): indexes the project and renders the template into a WebView panel beside the editor. Each template gets its own panel, with a refresh button in the panel’s title bar to re-render.

  • Edit template: open the .html.j2 file.

  • Open in Browser (inline button, plain templates only): render and write the HTML to the default output location (<reports-dir>/_build/…, exactly as ubc report) and open it in your external browser. Use this whenever a template relies on <script> or external CSS/JS frameworks, or when you just want the standalone, shareable file.

  • Render an insight: an insight’s template is listed with an insight · … description. A project-wide one (input = "none") renders from the view, by a click or its play button, into an insight panel. For one that takes needs, the play button is disabled, and its tooltip says how to render it: from a need’s CodeLens, or with ubc report <name> --need <ID> (see Rendering an insight).

  • Generate starter report template: shown when the project has no templates yet; scaffolds the same example as ubc quickstart.

Render failures (template syntax errors, runaway loops, oversized output) appear in the Problems panel as ubcode-report diagnostics on the template file, with the failing line where known. Diagnostics are published when a render is triggered — templates are not validated as you type — and clear again on the next successful render. An insight’s declaration is checked when ubproject.toml loads instead, and its errors appear on that file (see Queries and the template context).

Note

The in-editor preview renders static HTML only: <script> elements and externally loaded CSS/JS are not executed there (charts via inline SVG, as above, display fine). For templates that rely on scripts or external frameworks — or whenever you want the standalone, shareable file — use Open in Browser: it writes the report to _build/ (byte-identical to ubc report output) and opens it in your real browser, where everything runs without restriction.

Matching the editor theme

Rendered reports are self-contained documents with no dependency on VS Code. A template can nevertheless opt into the editor’s color theme. The simplest way is theme_style() (see Theme tokens: theme_style() below), which follows the editor’s theme in the preview and the browser’s light or dark mode everywhere else.

Theme tokens: theme_style()

theme_style() returns a block of CSS custom properties, the --ub-* design tokens. Put it inside a <style> element, ahead of your own rules, and style the page with var(--ub-…) only:

<style>
  {{ theme_style() }}
  body { font-family: var(--ub-font); color: var(--ub-fg); background: var(--ub-bg); }
  th { background: var(--ub-surface); }
  th, td { border: 1px solid var(--ub-border); }
  a { color: var(--ub-accent); }
</style>

It takes no argument. The block is CSS only, not a <style> element, so your own rules can share the element with it; it is written unescaped and is the same on every render for a host — it never contains project data.

Token

Meaning

In VS Code, follows

--ub-font

The text font

--vscode-font-family

--ub-font-mono

The code font (IDs, code)

--vscode-editor-font-family

--ub-fg

Text

--vscode-foreground

--ub-muted

Secondary text (captions, counts)

--vscode-descriptionForeground

--ub-bg

The page background

--vscode-editor-background

--ub-surface

A raised area: table headers, cards, chart boxes

--vscode-textCodeBlock-background

--ub-border

Borders and rules

--vscode-panel-border

--ub-accent

Links, focus, the highlighted item

--vscode-textLink-foreground

--ub-ok

A good state (covered, passed)

--vscode-charts-green

--ub-warn

A warning state

--vscode-list-warningForeground

--ub-error

A bad state (a gap, a failure)

--vscode-charts-red

What the block declares depends on where the page is shown:

  • In the VS Code preview, every token is the editor’s theme variable with a fixed fallback, e.g. --ub-fg: var(--vscode-foreground, #1f2328), so the page follows the active theme — light, dark, or high contrast — and changes with it. The fallback (the built-in color below, its dark value in a dark theme) only applies where a theme leaves a variable undefined.

  • Everywhere else — the ubc report file, Open in Browser, email, print — the block declares color-scheme: light dark, the built-in light colors (e.g. --ub-fg: #1f2328), and the dark ones under @media (prefers-color-scheme: dark), so the page follows the browser’s or the system’s light or dark mode. It references no --vscode-* variable.

The built-in colors are GitHub’s Primer palette in light and dark, chosen for legible contrast in both.

Using editor variables directly

A template can also reference VS Code’s CSS variables itself, with a fixed fallback:

:root {
  --report-fg: var(--vscode-foreground, #1f2328);
  --report-border: var(--vscode-panel-border, #d1d9e0);
}
body { color: var(--report-fg); font-family: var(--vscode-font-family, sans-serif); }

Inside the report preview the --vscode-* variables are defined by the editor, so the report follows the active theme (light, dark, or high contrast); in a browser, email client, or print output they are undefined and the fallback applies — the document renders identically to today, everywhere. Dark mode in a browser then needs fallbacks of its own under @media (prefers-color-scheme: dark), which theme_style() already provides. See the VS Code theme color reference for the available variables.

Tip

SVG presentation attributes cannot resolve var() — set fills via the style attribute (style="fill: var(--ub-accent)") or a CSS rule, as the quickstart template does.

Insights

Alpha

An insight is a report template plus a declaration in ubproject.toml: which needs it is rendered for, and which Cypher queries run before the template. The template then only renders results; the querying happens in the engine, in the same Cypher dialect as ubc query cypher.

[reports.insights.trace]          # the insight's name
title = "Trace"
description = "Up- and downstream of the need, with coverage gaps."
template = "trace.html.j2"          # relative to [reports].directory
input = "need"                      # "none" (default) | "need" | "needs"
types = ["req", "spec", "test"]     # need types it is offered for (empty: all)

[reports.insights.trace.queries]
upstream = { file = "queries/upstream.cypher" }       # relative to [reports].directory
downstream = { file = "queries/downstream.cypher" }

[reports.insights.stats]          # a second insight on the same types
title = "Stats"
description = "Downstream counts, coverage and status of the need, as charts."
template = "stats.html.j2"
input = "need"
types = ["req", "spec", "test"]

[reports.insights.stats.queries]
types = { file = "queries/stats_types.cypher" }
status = { file = "queries/stats_status.cypher" }
gaps = { file = "queries/stats_gaps.cypher" }

A query is either a file, as here, or the query text itself:

[reports.insights.trace.queries]
upstream = """
MATCH p = (c {id: $current_id})-[*1..5]->(u)
RETURN u.id AS id, u.type AS type, u.title AS title, u.status AS status,
       min(length(p)) AS depth
ORDER BY depth, id
"""

An insight’s name (its table key) starts with a letter or _, followed by letters, digits, _ or -, since it is also a path segment of the rendered page. For the same reason it is not a Windows device name (CON, PRN, AUX, NUL, COM1 to COM9, LPT1 to LPT9, in any letter case), and two insight names must not differ only in letter case (Trace and trace), which would write the same file on a case-insensitive file system: the one that sorts later (trace after Trace) is refused. A query’s name is read as queries.<name>, so it follows the same character rule without -. A declaration with any other name fails its render (declaration_invalid). A query file is read from inside [reports].directory only: its path is relative and /-separated, without a leading /, a drive (C:), .., hidden (.) segments or backslashes, like a template name; any other path fails the render (declaration_invalid) before the file is read.

A template without a declaration keeps rendering as before, as a plain report. It can call need_url() and theme_style() too, so a template that used either name for a variable of its own now gets the function instead. Both example insights are shown in full below: a trace table with gaps and statistics as charts.

Inputs

input says which needs a render takes, and how the queries and the template see them:

input

Cypher parameters

Template variables

none

—

—

need

$current_id, and $current_ids (one element)

current_need, current_needs

needs

$current_ids

current_needs

Needs enter a query only as parameters, never as query text. A render whose input does not match the declaration fails with a message instead of an empty page: no need for a need / needs insight (input_missing), needs for a none insight or several for a need one (input_unexpected), an unknown need id (input_unknown), or a need whose type is not in types (input_type). types only applies to the needs an insight takes: a none insight takes none, so its types is ignored, and it is never offered on a need: in VS Code it renders from the Reports view (see below). Every other insight must name need types the project declares: a render of one whose types names an undeclared type fails (declaration_invalid), since no need can be of that type.

Queries and the template context

Every declared query runs once per render, before the template, over one needs graph and under one deadline. The template receives each result as queries.<name>: a list of rows, each a mapping keyed by the RETURN aliases. A returned node becomes its property mapping (with id), a relationship {type, source, target} (need ids), and a path {nodes, relationships}. Only ORDER BY defines the row order.

Besides needs, needs_list and need_url(), an insight template sees:

  • queries — the query results, as above;

  • insight — {name, title, description, input};

  • current_need — the input need (input need), with the same fields as needs[id];

  • current_needs — the input needs, in the order given.

A query that does not parse or does not end with RETURN (query_syntax), references a parameter the insight does not supply (query_parameter), runs past the deadline of 10 seconds that all the queries of a render share, building the needs graph included (query_timeout), or returns more than 10,000 rows (query_budget) fails the render before the template runs, naming the query. So does a query that builds more than 4,000,000 intermediate rows on the way to its result (query_budget too), such as a cross product filtered by WHERE: that bounds the memory a mistyped query can take. A query whose last MATCH has no WHERE, and whose RETURN has no ORDER BY, DISTINCT or aggregation, stops as soon as it has one row too many; any other query is computed in full, within these limits, before its rows are counted. A template that does not exist (template_not_found) fails the render before any query runs.

The first two, and the declaration itself, do not wait for a render. In VS Code, every insight declaration is checked when ubproject.toml loads (when a project file is opened, on each save of ubproject.toml, and when a query file or template changes on disk; no query is run), and each problem a render would report before its template runs is shown as an error on its key in ubproject.toml, with the code and message the render would report:

  • a query that does not parse or has no final RETURN (query_syntax), uses a parameter its insight’s input does not supply (query_parameter), or names a query file that cannot be read (declaration_invalid): an inline query on its name = "…" line, a { file = "…" } query on the file name. A syntax error in an inline query is shown on the character where the query stops parsing, inside the string, also in a multi-line """…""" string; the --> line:column in its message counts from the start of the query, as ubc report prints it;

  • a template that does not exist in the reports directory (template_not_found) or is not a valid template name (declaration_invalid): on the template name;

  • a types entry that is not a declared need type (declaration_invalid): on the types list (not for a none insight, which ignores types);

  • an invalid insight or query name (declaration_invalid): on the name.

Only whether the template exists is checked there, not its text: a template error is reported by the render, on the template. A query is compiled there, not run, and its function names are not looked up: a misspelled function (toLowr(n.id)) is reported by the render, and only when a row reaches the call. ubc check does not run this check. The errors update when ubproject.toml is saved, and when a .cypher or .j2 file is saved, created or deleted, so fixing a query file, or adding the missing template, clears its error. A query file with another extension is re-checked at the next save of ubproject.toml.

Write a pattern with a variable-length step from the need in focus, (c {id: $current_id})<-[*1..5]-(d), or leave its first node bare (no label, no inline property), (d)-[*1..5]->(c {id: $current_id}). Both are expanded from c and return the same rows at the same cost. A first node with a label or an inline property is expanded from as written: (d:sw_req)-[*1..5]->(c {id: $current_id}) walks from every sw_req. A bare first node is left for the first node with the most of a label and an inline property. A node an earlier clause bound comes before all of these: the pattern starts there, wherever it is. The start moves only when the walk is bounded and every inline property is a literal or a parameter the query is given. The walk is bounded when its longest possible path — the longest length of every step added up, fixed steps included — is under 1,000: *1..5 is, * and *2.. are not. Otherwise, or with a property computed from something else ({id: toUpper($x)}), the pattern is expanded from its first node: written from the other end, it walks the paths of every need in the project before keeping the ones that end at c. On 200,000 needs, a five-hop walk from every need takes more than ten seconds, and from one need well under a second. The order of the rows can differ between the two forms; only ORDER BY defines it.

Rendering an insight

From the command line, name the insight and give the need(s) with --need:

ubc report trace --need REQ_AUTH

The page is written to <reports-dir>/_build/_insights/trace/REQ_AUTH.html (_insights/<name>.html without a need, or for several; a need given twice counts once), and -o/--outpath writes elsewhere. A need id that is a Windows device name (NUL, COM1, …) gets a leading _ in the file name (_NUL.html), since Windows would write a page named after it to the device. ubc report --list names the declared insights after the templates, with their input and types.

Each query an insight runs is logged, to debug a table that stays empty: ubc report -v prints each query’s row count and time, then its text, and in VS Code the ubCode Language Server output shows the same line per query (the text too when ubcode.server.logToOutput is debug).

In VS Code, every need whose type a need or needs insight accepts gets an insight CodeLens. With one such insight, the lens is named after it (Trace above), and clicking it renders that insight for the need, on demand, in a panel beside the editor. With two or more, they share one Insights (N) lens (Insights (2) for the trace and stats insights above): clicking it opens a picker listing each insight’s title and description, and picking one renders it in the same panel its own lens would. The panel’s refresh button re-renders the insight. A need linked with need_url() in the page (as in the example below) opens in the editor when clicked (see Linking a need).

The Reports view marks an insight’s template with the same icon and a description. A project-wide insight (insight · project-wide) renders from it into the same panel, by a click on the template or its play button. For an insight that takes needs (insight · for a req need) the play button is disabled, and its tooltip names the need types and how to render it: the need’s lens, or ubc report <name> --need <ID>. Open in Browser exports plain templates only.

Example: a trace table with gaps

This insight shows, for the need in focus, everything that traces to it and everything it traces to, and flags the gaps: a type of the expected req → spec → test chain that nothing below the need provides, and needs of a chain type below it that nothing traces to (open ends). The page reads top-down so that it stays usable on a large trace: a one-line summary with the counts (N downstream · M upstream · K gaps), the chain, the downstream and upstream tables, and the gaps last — the missing chain types first, then the open ends grouped per type, each group a <details> element showing the type and its count. A group lists its first 15 open ends and counts the rest (+N more), which the downstream table marks; the groups start expanded only while all the open ends fit in one group’s 15. Needs of a type outside the chain that nothing traces to are not gaps: the summary counts them apart (· J in other types), and one collapsed Other types group after the gaps lists them with their type, under the same 15 rule. The chain is drawn as inline SVG and the groups are plain HTML, so the page needs no JavaScript, and every need ID links to the need with need_url(). It is styled with the theme_style() tokens only (see Theme tokens: theme_style()), so it follows the editor’s theme in VS Code and the browser’s light or dark mode elsewhere.

reports/queries/upstream.cypher
// Everything the need in focus traces to (its links, transitively), nearest first.
MATCH p = (c {id: $current_id})-[*1..5]->(u)
RETURN u.id AS id, u.type AS type, u.title AS title, u.status AS status,
       min(length(p)) AS depth
ORDER BY depth, id
reports/queries/downstream.cypher
// Everything that traces to the need in focus, nearest first, with the number
// of needs tracing to each row (0 marks the end of a chain).
// The pattern starts at the need in focus and walks the links backwards,
// so the walk stays on its own trace instead of every path in the project.
// With a bounded range and an id given as a literal or a parameter,
// `(d)-[*1..5]->(c {id: $current_id})` is expanded from `c` too and costs the same;
// with an unbounded `*` a match is expanded from its first node, so start at `c`.
MATCH p = (c {id: $current_id})<-[*1..5]-(d)
RETURN d.id AS id, d.type AS type, d.title AS title, d.status AS status,
       min(length(p)) AS depth, COUNT { (d)<--() } AS traced_by
ORDER BY depth, id
reports/trace.html.j2
{#- Trace insight — the up- and downstream of ONE need, with coverage gaps.
    Declared in ubproject.toml as [reports.insights.trace] (input = "need"),
    with the `upstream` and `downstream` queries; render it with
    `ubc report trace --need <ID>`, or from the need's insight lens in VS Code.
    Script-free: the chart is inline SVG, so it renders the same everywhere. -#}
{#- The expected trace chain, from the top of the V down: a need is expected to
    be traced to by every type after its own. Adapt it to your project. -#}
{% set chain = ['req', 'spec', 'test'] %}
{% set need = current_need %}
{% set up = queries.upstream %}
{% set down = queries.downstream %}
{% set down_types = down|map(attribute='type')|list %}
{% set up_types = up|map(attribute='type')|list %}
{% set ns = namespace(position=-1) %}
{% for t in chain %}{% if t == need.type %}{% set ns.position = loop.index0 %}{% endif %}{% endfor %}
{% set position = ns.position %}
{% set expected = chain[position + 1:] if position >= 0 else [] %}
{% set missing = expected|reject('in', down_types)|list %}
{#- An open end is a need below the one in focus that nothing traces to. Only
    the chain's own types count as gaps (listed per type, in chain order); the
    open ends of any other type are listed apart, in one "Other types" group. -#}
{% set dead_ends = down|selectattr('traced_by', 'equalto', 0)|rejectattr('type', 'equalto', chain[-1])|list %}
{% set open_ends = dead_ends|selectattr('type', 'in', chain)|list %}
{% set other_ends = dead_ends|rejectattr('type', 'in', chain)|sort(attribute='type')|list %}
{% set open_types = open_ends|map(attribute='type')|unique|list %}
{% set open_groups = chain|select('in', open_types)|list %}
{% set other_types = other_ends|map(attribute='type')|unique|list %}
{#- Each group lists its first `shown` open ends; the chain groups start expanded
    only when all of them together fit in that, so a large trace stays one screen. -#}
{% set shown = 15 %}
{% set gap_count = missing|length + open_ends|length %}
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>{{ insight.title }} — {{ need.id }}</title>
  <style>
    {# The `--ub-*` design tokens: the editor's theme inside VS Code,
        built-in light and dark colors in a browser. #}
    {{ theme_style() }}
    body { font-family: var(--ub-font); color: var(--ub-fg); background: var(--ub-bg); max-width: 960px; margin: 1.5em auto; padding: 0 1em; }
    h1 { font-size: 1.4em; margin-bottom: 0.2em; }
    h2 { font-size: 1.1em; border-bottom: 1px solid var(--ub-border); padding-bottom: 4px; margin-top: 1.6em; }
    .muted { color: var(--ub-muted); }
    a { color: var(--ub-accent); }
    table { border-collapse: collapse; width: 100%; }
    th, td { border: 1px solid var(--ub-border); padding: 4px 8px; text-align: left; vertical-align: top; }
    th { background: var(--ub-surface); }
    td.depth { width: 3em; text-align: center; }
    .badge { display: inline-block; padding: 0 6px; border-radius: 9px; font-size: 0.85em; border: 1px solid currentColor; }
    .gap { color: var(--ub-error); }
    .ok { color: var(--ub-ok); }
    ul.gaps li { margin: 2px 0; }
    details { border: 1px solid var(--ub-border); border-radius: 6px; margin: 6px 0; padding: 4px 10px; }
    details[open] > summary { margin-bottom: 4px; }
    summary { cursor: pointer; }
  </style>
</head>
<body>
  <h1>{{ insight.title }}: {{ need.id }}</h1>
  <p class="muted">{{ need.type }} · {{ need.title }}{% if need.status %} · status {{ need.status }}{% endif %}</p>
  {#- The whole picture in one line; the gaps themselves are listed after the tables. #}
  {% set parts = [] %}
  {% if missing %}{% set parts = parts + [missing|length ~ ' missing type' ~ ('' if missing|length == 1 else 's')] %}{% endif %}
  {% if open_ends %}{% set parts = parts + [open_ends|length ~ ' open end' ~ ('' if open_ends|length == 1 else 's')] %}{% endif %}
  <p class="summary">{{ down|length }} downstream · {{ up|length }} upstream · {% if gap_count -%}
      <a class="gap" href="#gaps">{{ gap_count }} gap{{ '' if gap_count == 1 else 's' }}</a> ({{ parts|join(', ') }})
    {%- else -%}
      <span class="ok">No gaps</span>
    {%- endif %}{% if other_ends %} · <a class="muted" href="#other-types">{{ other_ends|length }} in other types</a>{% endif %}</p>

  {#- The chain, one box per type: how many needs of it the trace reaches.
      Types missing below the need in focus are drawn as dashed gaps. #}
  {% set box_w = 150 %}
  {% set step = 190 %}
  {#- A viewBox with a relative width: the chain scales down in a narrow panel
      instead of being cropped. #}
  <svg viewBox="0 0 {{ step * (chain|length) }} 74" width="100%" style="max-width: {{ step * (chain|length) }}px;"
       role="img" aria-label="Trace chain">
    {% for t in chain %}
      {% set x = loop.index0 * step + 4 %}
      {% if t == need.type %}
        {% set count = 1 %}{% set kind = 'focus' %}
      {% elif loop.index0 < position %}
        {% set count = up_types|select('equalto', t)|list|length %}{% set kind = 'up' %}
      {% else %}
        {% set count = down_types|select('equalto', t)|list|length %}{% set kind = 'down' %}
      {% endif %}
      {% set is_gap = t in missing %}
      <rect x="{{ x }}" y="8" width="{{ box_w }}" height="52" rx="6"
            style="fill: var(--ub-surface); stroke: {{ 'var(--ub-error)' if is_gap else ('var(--ub-accent)' if kind == 'focus' else 'var(--ub-border)') }}; stroke-width: {{ 2 if kind == 'focus' or is_gap else 1 }};{% if is_gap %} stroke-dasharray: 5 4;{% endif %}">
        <title>{{ count }} {{ t }} need(s){% if is_gap %} — none traces to {{ need.id }}{% endif %}</title>
      </rect>
      <text x="{{ x + box_w / 2 }}" y="30" text-anchor="middle" style="fill: var(--ub-muted); font-size: 12px;">{{ t }}</text>
      <text x="{{ x + box_w / 2 }}" y="50" text-anchor="middle"
            style="fill: {{ 'var(--ub-error)' if is_gap else 'var(--ub-fg)' }}; font-size: 16px; font-weight: 600;">{{ 'gap' if is_gap else count }}</text>
      {% if not loop.last %}
        <line x1="{{ x + box_w + 4 }}" y1="34" x2="{{ x + step - 6 }}" y2="34" style="stroke: var(--ub-muted); stroke-width: 2;" />
      {% endif %}
    {% endfor %}
  </svg>

  <h2 id="downstream">Downstream <span class="muted">— traces to {{ need.id }} ({{ down|length }})</span></h2>
  {% if down %}
    <table>
      <tr><th>Depth</th><th>ID</th><th>Type</th><th>Title</th><th>Status</th><th>Coverage</th></tr>
      {% for r in down %}
        <tr>
          <td class="depth">{{ r.depth }}</td><td><a href="{{ need_url(r.id) }}">{{ r.id }}</a></td><td>{{ r.type }}</td>
          <td>{{ r.title }}</td><td>{{ r.status }}</td>
          <td>
            {% if r.traced_by == 0 and r.type != chain[-1] %}<span class="badge {{ 'gap' if r.type in chain else 'muted' }}">open end</span>
            {% elif r.traced_by == 0 %}<span class="badge ok">leaf</span>
            {% else %}<span class="muted">{{ r.traced_by }} trace(s)</span>{% endif %}
          </td>
        </tr>
      {% endfor %}
    </table>
  {% else %}
    <p class="muted">Nothing traces to {{ need.id }}.</p>
  {% endif %}

  <h2>Upstream <span class="muted">— {{ need.id }} traces to ({{ up|length }})</span></h2>
  {% if up %}
    <table>
      <tr><th>Depth</th><th>ID</th><th>Type</th><th>Title</th><th>Status</th></tr>
      {% for r in up %}
        <tr>
          <td class="depth">{{ r.depth }}</td><td><a href="{{ need_url(r.id) }}">{{ r.id }}</a></td><td>{{ r.type }}</td>
          <td>{{ r.title }}</td><td>{{ r.status }}</td>
        </tr>
      {% endfor %}
    </table>
  {% else %}
    <p class="muted">{{ need.id }} traces to nothing{% if position > 0 %} — expected a {{ chain[position - 1] }}{% endif %}.</p>
  {% endif %}

  <h2 id="gaps">Gaps</h2>
  {% if gap_count %}
    {% if missing %}
      <ul class="gaps">
        {% for t in missing %}
          <li class="gap">No <strong>{{ t }}</strong> traces to {{ need.id }}.</li>
        {% endfor %}
      </ul>
    {% endif %}
    {% if open_ends %}
      <p>Open ends — needs below {{ need.id }} that nothing traces to, per type:</p>
      {% for t in open_groups %}
        {% set rows = open_ends|selectattr('type', 'equalto', t)|list %}
        <details{% if open_ends|length <= shown %} open{% endif %}>
          <summary><strong>{{ t }}</strong> <span class="badge gap">{{ rows|length }} open end{{ '' if rows|length == 1 else 's' }}</span></summary>
          <ul class="gaps">
            {% for r in rows[:shown] %}
              <li><a href="{{ need_url(r.id) }}">{{ r.id }}</a> <span class="muted">{{ r.title }}</span></li>
            {% endfor %}
            {% if rows|length > shown %}
              <li class="muted">+{{ rows|length - shown }} more, marked open end in the <a href="#downstream">Downstream</a> table.</li>
            {% endif %}
          </ul>
        </details>
      {% endfor %}
    {% endif %}
  {% else %}
    <p class="ok">No gaps: the chain below {{ need.id }} is complete.</p>
  {% endif %}
  {% if other_ends %}
    {#- Not counted as gaps: these types are not in the chain. Always collapsed. #}
    <details id="other-types">
      <summary><strong>Other types</strong> <span class="badge muted">{{ other_ends|length }} open end{{ '' if other_ends|length == 1 else 's' }}</span>
        <span class="muted">{{ other_types|join(', ') }} — not in the chain, not counted as gaps</span></summary>
      <ul class="gaps">
        {% for r in other_ends[:shown] %}
          <li><a href="{{ need_url(r.id) }}">{{ r.id }}</a> <span class="muted">{{ r.type }} · {{ r.title }}</span></li>
        {% endfor %}
        {% if other_ends|length > shown %}
          <li class="muted">+{{ other_ends|length - shown }} more, marked open end in the <a href="#downstream">Downstream</a> table.</li>
        {% endif %}
      </ul>
    </details>
  {% endif %}
</body>
</html>

Example: statistics as charts

The stats insight counts the same downstream as the trace example (everything that traces to the need in focus, up to five links away) and shows it as figures and charts instead of tables: three tiles (the downstream total, its coverage, its open needs), a bar chart of the downstream per type, a stacked bar per chain type with its covered needs and gaps, the status split (done, open, no status) with a bar per status, and the nearest gaps, linked with need_url(). A need of a chain type is covered when a need of the next chain type links to it directly, and a gap otherwise; as in the trace example, the end of the chain and the types outside it are no gaps.

The queries do all the counting, so the page stays one screen however large the trace is, and they also hold the req → spec → test chain and the statuses that count as done: adapt those to your project in the queries. Each chart row is an HTML label, an inline SVG bar that stretches with the panel, and an HTML value, so the page needs no JavaScript and its text keeps its size in a narrow editor panel. It is styled with the theme_style() tokens only, like the trace example.

Declared on the same types as trace (see the declaration above), it gives every req, spec and test need an Insights (2) lens in VS Code, whose picker offers both insights.

reports/queries/stats_types.cypher
// Everything that traces to the need in focus (its downstream), counted per type.
// `pos` is the type's place in the req -> spec -> test chain (99 outside it),
// `next` the type expected to trace to it (null at the end of the chain and
// outside it). A need is `covered` when at least one need of its next type
// links to it directly, and `open` when it has a status that is not a done one;
// a need without a status is neither open nor done.
// Adapt the chain (`pos`, `next`) and the done statuses to your project,
// and keep them in step with stats_status.cypher and stats_gaps.cypher.
MATCH (c {id: $current_id})<-[*1..5]-(d)
WITH DISTINCT d
WITH d,
     CASE d.type WHEN 'req' THEN 0 WHEN 'spec' THEN 1 WHEN 'test' THEN 2 ELSE 99 END AS pos,
     CASE d.type WHEN 'req' THEN 'spec' WHEN 'spec' THEN 'test' END AS next
RETURN d.type AS type, pos, next,
       count(*) AS total,
       sum(CASE WHEN next IS NOT NULL AND COUNT { (d)<--(x) WHERE x.type = next } > 0 THEN 1 ELSE 0 END) AS covered,
       sum(CASE WHEN d.status IS NOT NULL AND d.status <> ''
                 AND NOT d.status IN ['done', 'closed', 'approved', 'accepted', 'released', 'verified', 'implemented']
                THEN 1 ELSE 0 END) AS open
ORDER BY pos, type
reports/queries/stats_status.cypher
// The downstream of the need in focus, counted per status.
// `state` classifies a status for the chart's colors: done, open,
// or none (no status set). The done statuses are those of stats_types.cypher.
MATCH (c {id: $current_id})<-[*1..5]-(d)
WITH DISTINCT d
WITH CASE WHEN d.status IS NULL OR d.status = '' THEN '' ELSE d.status END AS status
WITH status,
     CASE WHEN status = '' THEN 'none'
          WHEN status IN ['done', 'closed', 'approved', 'accepted', 'released', 'verified', 'implemented'] THEN 'done'
          ELSE 'open' END AS state
RETURN status, state, count(*) AS n
ORDER BY n DESC, status
reports/queries/stats_gaps.cypher
// The first gaps below the need in focus: needs of a chain type that no need of
// the next chain type links to (the chain of stats_types.cypher).
// Nearest first, then in chain order and in natural ID order (REQ_9 before REQ_10).
// Only the first 10 are returned; stats_types.cypher counts them all.
MATCH p = (c {id: $current_id})<-[*1..5]-(d)
WITH d, min(length(p)) AS depth,
     CASE d.type WHEN 'req' THEN 'spec' WHEN 'spec' THEN 'test' END AS next,
     CASE d.type WHEN 'req' THEN 0 WHEN 'spec' THEN 1 WHEN 'test' THEN 2 ELSE 99 END AS pos
WHERE next IS NOT NULL AND COUNT { (d)<--(x) WHERE x.type = next } = 0
RETURN d.id AS id, d.type AS type, d.title AS title, next, depth
ORDER BY depth, pos, size(id), id
LIMIT 10
reports/stats.html.j2
{#- Stats insight — the downstream of ONE need, counted and drawn as charts.
    Declared in ubproject.toml as [reports.insights.stats] (input = "need"),
    with the `types`, `status` and `gaps` queries; render it with
    `ubc report stats --need <ID>`, or from the need's insight lens in VS Code.
    Script-free: every bar is inline SVG, so it renders the same everywhere. -#}
{#- The queries do the counting, so they also hold the expected trace chain
    (req → spec → test, the same as the trace insight's) and the statuses
    that count as done. Adapt them there; the template only lays the rows out
    and stays one screen on any project size. -#}
{% set need = current_need %}
{% set types = queries.types %}
{% set statuses = queries.status %}
{% set gaps = queries.gaps %}
{#- A need of a chain type that has a next type is covered when a need of that
    next type traces to it, and a gap otherwise. The last chain type and the
    types outside the chain (no `next`) are neither, as in the trace insight. -#}
{% set chained = types|rejectattr('next', 'none')|list %}
{% set total = types|map(attribute='total')|sum %}
{% set eligible = chained|map(attribute='total')|sum %}
{% set covered = chained|map(attribute='covered')|sum %}
{% set gap_count = eligible - covered %}
{% set open = types|map(attribute='open')|sum %}
{% set max_total = types|map(attribute='total')|max if types else 1 %}
{% set fill = {'done': 'var(--ub-ok)', 'open': 'var(--ub-warn)', 'none': 'var(--ub-muted)'} %}
{#- One bar: `parts` is a list of [width in percent, fill, tooltip]. The bar
    stretches with the panel; its label and value stay HTML text around it. -#}
{% macro bar(parts) -%}
  <svg class="bar" viewBox="0 0 100 10" preserveAspectRatio="none" aria-hidden="true">
    {%- set ns = namespace(x=0) %}
    {%- for w, f, tip in parts if w > 0 %}
    <rect x="{{ ns.x|round(2) }}" y="0" width="{{ [w, 0.4]|max|round(2) }}" height="10" style="fill: {{ f }};"><title>{{ tip }}</title></rect>
    {%- set ns.x = ns.x + w %}
    {%- endfor %}
  </svg>
{%- endmacro %}
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>{{ insight.title }} — {{ need.id }}</title>
  <style>
    {# The `--ub-*` design tokens: the editor's theme inside VS Code,
        built-in light and dark colors in a browser. #}
    {{ theme_style() }}
    body { font-family: var(--ub-font); color: var(--ub-fg); background: var(--ub-bg); max-width: 960px; margin: 1.5em auto; padding: 0 1em; }
    h1 { font-size: 1.4em; margin-bottom: 0.2em; }
    h2 { font-size: 1.1em; border-bottom: 1px solid var(--ub-border); padding-bottom: 4px; margin-top: 1.6em; }
    h3 { font-size: 0.95em; font-weight: 600; margin: 1.2em 0 0.4em; }
    .muted { color: var(--ub-muted); }
    a { color: var(--ub-accent); }
    .id { font-family: var(--ub-font-mono); }
    .tiles { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 1em; }
    .tile { flex: 1 1 180px; border: 1px solid var(--ub-border); border-radius: 8px; padding: 10px 14px; background: var(--ub-surface); }
    .tile .k { color: var(--ub-muted); font-size: 0.85em; text-transform: uppercase; letter-spacing: 0.04em; }
    .tile .v { font-size: 2em; font-weight: 600; line-height: 1.25; font-variant-numeric: tabular-nums; }
    .tile .s { color: var(--ub-muted); font-size: 0.85em; }
    .tile svg.bar { height: 8px; margin: 4px 0; }
    .gap { color: var(--ub-error); }
    .ok { color: var(--ub-ok); }
    .warn { color: var(--ub-warn); }
    .nw { white-space: nowrap; }
    /* Bar charts, one grid row per bar: label | bar | value. */
    .bars { display: grid; grid-template-columns: 8.5em minmax(80px, 1fr) minmax(4.5em, max-content); gap: 6px 10px; align-items: center; max-width: 820px; }
    .bars .lbl { text-align: right; overflow-wrap: anywhere; }
    .bars .val { font-weight: 600; font-variant-numeric: tabular-nums; white-space: nowrap; }
    .bars .val .muted { font-weight: normal; }
    svg.bar { display: block; width: 100%; height: 18px; }
    svg.bar rect[style*="--ub-muted"] { fill-opacity: 0.7; }
    /* High contrast themes in VS Code (a class on the body): outline the bars and swatches. */
    body.vscode-high-contrast svg.bar, body.vscode-high-contrast .swatch { outline: 1px solid var(--ub-border); outline-offset: 1px; }
    .legend { display: flex; flex-wrap: wrap; gap: 14px; font-size: 0.9em; margin: 6px 0 0; padding: 0; list-style: none; }
    .swatch { display: inline-block; width: 11px; height: 11px; border-radius: 2px; margin-right: 5px; vertical-align: -1px; }
    .swatch.none { opacity: 0.7; }
    .note { font-size: 0.85em; }
    ul.gaps { padding-left: 1.2em; }
    ul.gaps li { margin: 2px 0; }
  </style>
</head>
<body>
  <h1>{{ insight.title }}: {{ need.id }}</h1>
  <p class="muted">{{ need.type }} · {{ need.title }}{% if need.status %} · status {{ need.status }}{% endif %}</p>

  {% if not total %}
  <p class="muted">Nothing traces to {{ need.id }}, so there is nothing to count.</p>
  {% else %}

  {#- 1. Key figures: the downstream, its coverage, its open needs. #}
  <div class="tiles">
    <div class="tile">
      <div class="k">Downstream</div>
      <div class="v">{{ total }}</div>
      <div class="s">need{{ '' if total == 1 else 's' }} trace to {{ need.id }}, {{ types|length }} type{{ '' if types|length == 1 else 's' }}</div>
    </div>
    <div class="tile">
      <div class="k">Coverage</div>
      {% if eligible %}
        {#- Floored, so a gap never shows as 100 %. #}
        {% set pct = (covered * 1000 // eligible) / 10 %}
        <div class="v {{ 'ok' if gap_count == 0 else '' }}">{{ pct }}<span class="muted" style="font-size: 0.55em;"> %</span></div>
        {{ bar([[covered / eligible * 100, 'var(--ub-ok)', covered ~ ' covered'], [gap_count / eligible * 100, 'var(--ub-error)', gap_count ~ ' gaps']]) }}
        <div class="s">{{ covered }} of {{ eligible }} covered by the next type · <span class="nw {{ 'gap' if gap_count else 'ok' }}">{{ gap_count }} gap{{ '' if gap_count == 1 else 's' }}</span></div>
      {% else %}
        <div class="v muted">—</div>
        <div class="s">no need below {{ need.id }} expects a further chain type</div>
      {% endif %}
    </div>
    <div class="tile">
      <div class="k">Open</div>
      <div class="v {{ 'warn' if open else '' }}">{{ open }}</div>
      {% set none_n = statuses|selectattr('state', 'equalto', 'none')|map(attribute='n')|sum %}
      <div class="s">with a status that is not done{% if none_n %} · <span class="nw">{{ none_n }} without a status</span>{% endif %}</div>
    </div>
  </div>

  {#- 2. Downstream needs per type: the chain types in chain order, then the others. #}
  <h2>Downstream per type</h2>
  <div class="bars" id="per-type">
    {% for r in types %}
      <span class="lbl">{{ r.type }}</span>
      {{ bar([[r.total / max_total * 100, 'var(--ub-accent)', r.type ~ ': ' ~ r.total]]) }}
      <span class="val" data-type="{{ r.type }}" data-n="{{ r.total }}">{{ r.total }}</span>
    {% endfor %}
  </div>

  {#- 3. Coverage per chain type: covered vs gap, stacked to 100 %. #}
  <h2>Coverage per type <span class="muted">— covered by the next chain type</span></h2>
  {% if chained %}
  <div class="bars" id="coverage">
    {% for r in chained %}
      <span class="lbl">{{ r.type }}</span>
      {{ bar([[r.covered / r.total * 100, 'var(--ub-ok)', r.covered ~ ' covered by a ' ~ r.next], [(r.total - r.covered) / r.total * 100, 'var(--ub-error)', (r.total - r.covered) ~ ' without a ' ~ r.next]]) }}
      <span class="val" data-type="{{ r.type }}" data-covered="{{ r.covered }}" data-total="{{ r.total }}">{{ r.covered * 100 // r.total }} % <span class="muted">{{ r.covered }}/{{ r.total }}</span></span>
    {% endfor %}
  </div>
  <ul class="legend">
    <li><span class="swatch" style="background: var(--ub-ok);"></span>covered</li>
    <li><span class="swatch" style="background: var(--ub-error);"></span>gap — no next-type need traces to it</li>
  </ul>
  {% else %}
  <p class="muted">No chain type below {{ need.id }} expects a further type.</p>
  {% endif %}
  {#- The query puts a type outside the chain at position 99. #}
  {% set chain_end = types|selectattr('next', 'none')|rejectattr('pos', 'equalto', 99)|map(attribute='type')|list %}
  {% set other_types = types|selectattr('pos', 'equalto', 99)|map(attribute='type')|list %}
  {% if chain_end or other_types %}
  <p class="muted note">Not shown, as no gaps:
    {%- if chain_end %} {{ chain_end|join(', ') }} (end of the chain){% endif %}
    {%- if chain_end and other_types %};{% endif %}
    {%- if other_types %} {{ other_types|join(', ') }} (not in the chain){% endif %}.</p>
  {% endif %}

  {#- 4. Status: the whole downstream by state, then the set statuses on their own scale. #}
  <h2>Status</h2>
  {% set n_done = statuses|selectattr('state', 'equalto', 'done')|map(attribute='n')|sum %}
  {% set n_open = statuses|selectattr('state', 'equalto', 'open')|map(attribute='n')|sum %}
  {% set n_none = statuses|selectattr('state', 'equalto', 'none')|map(attribute='n')|sum %}
  <div class="bars">
    <span class="lbl">all</span>
    {{ bar([[n_done / total * 100, fill.done, 'done: ' ~ n_done], [n_open / total * 100, fill.open, 'open: ' ~ n_open], [n_none / total * 100, fill.none, 'no status: ' ~ n_none]]) }}
    <span class="val">{{ total }}</span>
  </div>
  <ul class="legend">
    {% for state, label in [('done', 'done'), ('open', 'open'), ('none', 'no status')] %}
      {% set n = statuses|selectattr('state', 'equalto', state)|map(attribute='n')|sum %}
      <li><span class="swatch {{ state }}" style="background: {{ fill[state] }};"></span>{{ label }} <strong>{{ n }}</strong></li>
    {% endfor %}
  </ul>
  {% set set_statuses = statuses|rejectattr('state', 'equalto', 'none')|list %}
  {% if set_statuses %}
  {% set max_status = set_statuses|map(attribute='n')|max %}
  <h3>Needs per status <span class="muted">— the {{ set_statuses|map(attribute='n')|sum }} with a status, scaled to the largest</span></h3>
  <div class="bars" id="per-status">
    {% for r in set_statuses %}
      <span class="lbl">{{ r.status }}</span>
      {{ bar([[r.n / max_status * 100, fill[r.state], r.status ~ ' (' ~ r.state ~ '): ' ~ r.n]]) }}
      <span class="val" data-status="{{ r.status }}" data-n="{{ r.n }}">{{ r.n }}</span>
    {% endfor %}
  </div>
  {% endif %}

  {#- 5. The nearest gaps, linked; the query returns the first 10. #}
  <h2>Nearest gaps <span class="muted">— {{ gaps|length }} of {{ gap_count }}</span></h2>
  {% if gaps %}
  <ul class="gaps">
    {% for r in gaps %}
      <li><a class="id" href="{{ need_url(r.id) }}">{{ r.id }}</a> <span class="muted">{{ r.type }} · {{ r.title }} — no {{ r.next }}</span></li>
    {% endfor %}
    {% if gap_count > gaps|length %}<li class="muted">+{{ gap_count - gaps|length }} more</li>{% endif %}
  </ul>
  {% else %}
  <p class="ok">No gaps: every chain need below {{ need.id }} is covered.</p>
  {% endif %}
  {% endif %}
</body>
</html>

Configuration

The [reports] section of ubproject.toml has two keys, plus the insight declarations described above:

[reports]
# directory containing .html.j2 templates,
# relative to this file (default: "reports")
directory = "reports"
# abort renders whose output exceeds this many bytes
# (default: 50 MiB)
max_output_bytes = 52428800

Render-time safety limits beyond the output cap (a deterministic computation budget that terminates runaway loops, a template recursion limit, and for an insight a 10-second deadline for all its queries, at most 10,000 rows per query, and at most 4,000,000 intermediate rows in any one step of a query) are internal to the renderer and need no configuration.

Failure modes

ubc report exits non-zero with a descriptive message when:

  • no template name is given and --list is not used, or both a template name and --list are given (usage error),

  • the template name cannot be resolved (a path segment starting with ., such as a hidden file or .. traversal, or an absolute name: a leading / or a drive such as C:) or does not exist under the reports directory, and is not a declared insight either (usage error; --list names the templates and the declared insights, the insights even when there is no template),

  • the template fails to parse or render (the message includes the template name and, where known, the line number),

  • the rendered output exceeds max_output_bytes,

  • the render exhausts its computation budget (e.g. an unbounded loop),

  • an insight’s input or one of its queries fails (see Insights); --need given for a name that is not a declared insight is a usage error,

  • the template name is the template of a declared insight (insight_template): such a template reads queries and the other insight variables, which only an insight render provides, so it is refused before indexing. The message names the insight, and the command line adds the command to render it with, for example ubc report trace --need <ID>. In VS Code, the Reports view marks such a template as an insight: a project-wide (input = "none") insight renders from it, and for an insight that takes needs its play button is disabled; clicking that template names the insight instead and says where the editor offers it: a need’s CodeLens — its own, or its entry in the Insights lens when several insights apply to the need.

Without an active license, rendering is limited to small projects, matching the limits of ubc build needs; the same need limit applies when rendering from VS Code. See What works without a license for the numbers.