Architecture
This document describes the architecture of the Antora Tracer extension using selected arc42 sections.
Each section is a traceable [item] that references the requirements it addresses.
Introduction and Goals
Project scope and architectural goals
The Antora Tracer is an Antora extension that adds role-based requirements traceability to AsciiDoc documentation.
It replaces fixed macro types with a single configurable [item] macro.
Architectural goals:
-
Enable users to define their own traceability domain model (roles, relations, matrices) through YAML configuration
-
Maintain a clean separation between parsing, graph storage, validation, matrix generation, and export
-
Ship with built-in presets for common domains without requiring users to write configuration
-
Integrate with Antora’s event pipeline without imposing runtime dependencies on users who don’t need specific features
-
Provide a standalone CLI for use outside Antora
Solution Strategy
The following items describe how individual quality goals are addressed by architecture decisions documented in the ADRs and quality acceptance criteria in the Quality Attributes.
Zero operational overhead is achieved through an in-memory graph (ADR-003) no external services, and a minimal dependency tree (ADR-004). Optional features like Neo4j export degrade gracefully without warnings.
Configurable without code changes
is achieved through YAML-based roles, relations, and matrices via
ConfigLoader.
Built-in presets can be extended or overridden.
Items with unknown roles generate warnings, not errors — enabling
incremental domain model adoption.
Fail-fast with clear diagnostics is achieved through role-based relation validation at processing time and post-hoc re-validation for cross-file references. Error messages include file, line, item IDs with roles, and allowed alternatives.
No side effects on source files
is achieved by performing all content transformations on in-memory
buffers.
Source .adoc files are never written to.
Processing is idempotent.
Testability by design is achieved through constructor-based dependency injection isolated test files per module, and graph state exposed for test access. The regex-based parser (ADR-002) is decoupled from Asciidoctor’s API.
Performance is achieved through forward, reverse, and inverse
relationship indexes (O(1) lookup), result caching with invalidation
on mutation, and BFS path finding with configurable maxDepth.
Building Block View
The extension is composed of eleven components organized in four layers. Arrows indicate dependencies, with constructor-injected dependencies labeled explicitly.
| Component | Responsibility | Key dependencies |
|---|---|---|
|
Antora entry point. Registers |
|
CLI ( |
Standalone CLI. Same core extension as Antora but without event handlers. Commander-based subcommands for processing, matrices, validation, export, next-id. |
|
|
Orchestrator. Owns the |
|
|
Regex-based AsciiDoc parser. Extracts |
|
|
In-memory directed graph. Forward and reverse relationship indexes for O(1) lookups. Provides query methods ( |
|
|
Loads built-in preset YAML, merges with user-provided config. Exposes |
Built-in presets ( |
|
Config-driven matrix generation. Reads matrix definitions from config, queries graph for items by role, computes coverage with |
|
|
Mustache wrapper. Loads templates from |
Mustache templates |
|
Path resolution for matrix-to-item deep links. Normalizes |
None (standalone utility) |
|
Graph-to-Neo4j CSV + Cypher export. Produces |
|
The following class diagram shows the public API of each component — the method groups and key signatures that form the contract boundaries between modules. See the API reference for full signatures.
ConfigLoader
Presets are YAML files following a defined structure. Each preset is a self-contained versioned configuration that users can extend or override.
| Key | Type | Description |
|---|---|---|
|
string |
Unique preset identifier (e.g., |
|
multiline string |
Human-readable explanation of the preset’s domain, roles, and intended use. |
|
semver string |
Independent version number for the preset, separate from the extension version. |
|
string |
Preset author or organization. |
|
string[] |
Keywords for categorisation (e.g., |
|
object |
|
|
string[] |
Ordered list of valid role names. Items must use one of these roles. |
|
map |
|
|
object[] |
Array of matrix definitions. Each has |
|
object[] |
Optional array of named Cypher queries ( |
|
object |
|
Built-in presets and user config are merged by ConfigLoader.
The resolved configuration is memoized and consumed independently by each component.
| Element | Description |
|---|---|
Built-in presets |
YAML files in |
User config ( |
Optional YAML file. Deep-merged on top of the preset. Overrides roles, adds custom matrices, extends relations. Path configured via |
|
Loads preset, deep-merges user config, memoizes the result. Exposes query methods: |
Resolved configuration |
Memoized merge of preset + user overrides. Four sub-components: |
Consumers |
|
Relation validation is driven by the YAML configuration rather than hardcoded
rules. ConfigLoader.isRelationAllowed() checks source role, target role
and relation type against the configured relations map.
Cross-file
references are re-validated after all files are loaded to catch late-resolved
targets.
Error messages include file path, line number, and allowed
alternatives (see ADR-004).
A preset may declare a top-level extends field naming a parent preset.
ConfigLoader.loadPreset() recursively loads the parent and deep-merges it under the child via mergeConfig(): roles are unioned, relations deep-merged, matrices overridden by name, and labels overridden key-by-key.
Inheritance resolves transitively; a missing parent raises a "not found" error, and a circular chain raises a circular-inheritance error.
The validate() method calls findCircularReferences(), a DFS-based cycle
detector that traverses all items and their outgoing relationships.
A path stack
and recursion set track visited nodes; when a node is encountered twice in the
same path, a circular reference error is reported with the full cycle path
(e.g., "REQ-001 → REQ-002 → REQ-001").
Auto-generated inverse relationships
are skipped to avoid false positives.
The registerContentClassifier() handler processes both family: page and
family: partial files.
Partial items use Antora’s pre-computed fileUri
as their sourceFile, making traceability matrix links point directly to the
partial source in the content repository (e.g., GitHub blob URL).
The
LinkResolver detects URL sourceFile values and returns them unchanged,
skipping the HTML path conversion.
In buildXref(), URL-based items use
AsciiDoc’s link: macro instead of xref: since Asciidoctor cannot resolve
external URLs as cross-references.
All three passes (graph population, macro
expansion, and link substitution) apply to partials as well as pages, because
partial content is inlined into pages and reaches the browser.
TraceabilityGraph
All content transformations — parsing, macro expansion, link substitution
title injection — operate on in-memory buffers.
Source .adoc files are never
written to.
The Antora content catalog provides the text buffers; the extension
modifies them in place and writes them back to the catalog without touching disk.
This ensures idempotency and version-control safety (see
ADR-003).
The graph has a well-defined lifecycle — understanding which operations are valid in each state explains why pass ordering matters:
toDot(fromId, depth?) generates GraphViz DOT source for a relationship graph
using BFS traversal in both outgoing and incoming directions from the given item.
Nodes are colored by role using a
shared role-color palette; edges are labeled with the relationship type.
Depth limits
hopping. toVegaLite(itemId?) generates a Vega-Lite JSON spec for coverage
charts — per-relationship-type for a specific item, or global role counts
when called without an item ID.
toConfigDot(config) renders the traceability configuration as DOT — roles as nodes and declared relations as labeled edges.
Each method produces a source string that is
encoded into Kroki URLs for rendering.
The relations map is keyed as sourceRole → targetRole → type → { reverse }, each relation type declaring its authorable reverse name.
TraceabilityGraph.addRelationship() canonicalizes a reverse-authored edge to the primary direction — authoring is_derived_from from a requirement stores UC → REQ : leads_to.
Authoring the same logical edge from both sides deduplicates into one bidirectional edge driven by the reverse declaration.
ConfigLoader.isRelationAllowed() derives the reverse direction, so the reverse type is allowed without a second relations entry.
AntoraTraceabilityExtension
The extension hooks into Antora’s event pipeline via register():
contentClassified triggers parsing, macro expansion, and matrix
registration in the content catalog; sitePublished writes the standalone
traceability output directory.
Each event handler is self-contained, does
not block the build pipeline, and receives the content catalog as a
parameter.
The same RequirementsTraceabilityExtension class is used by
the CLI outside Antora, demonstrating the event handlers are optional.
The expandRelationMacros() method in the Antora extension (not the
DocumentParser) replaces traceability:links[] and
traceability:incoming[] placeholders with formatted AsciiDoc content
showing the item’s relationships.
All three rendering macros (outgoing
incoming, and links) share this single scan/render pipeline; only the
relationship direction(s) they emit differ.
The DocumentParser’s inline
macro regex excludes these via a traceability: namespace guard; they are
NOT parsed as relationships.
Rendering is opt-in via the
:traceability-links: document attribute.
Display style (list, table
inline), sort order (target ID, title, relation type), and collapsible
sections are configurable via document attributes.
Inline relationship
macros (``) are always stripped from output — they are
pure data markers; the link macros are the sole rendering mechanism.
-
REQ-044 — traceability:outgoing[ macro renders outgoing links]
-
REQ-055 — traceability:incoming[ macro renders incoming links]
-
REQ-100 — Inline macros suppressed when links macros are active
-
REQ-102 — Collapsible list-style output via document attribute
-
REQ-104 — traceability:links[ macro renders combined outgoing and incoming links]
Relation types are displayed through a labels map in the config, mapping a type to a human-readable name.
The map is display-only and never affects graph structure, merge, or validation.
When a type has no labels entry, humanize(type) renders it: underscores become spaces, sentence-cased (is_derived_from → "Is derived from").
Incoming links use the reverse type’s label.
The expandGraphMacros(), expandCoverageMacros(), and expandConfigGraphMacros() methods replace
traceability:graph[], traceability:graph-coverage[], and `
image::https://kroki.io/graphviz/svg/eJyFkUFLwzAYhu_7FaFeK0yY4pAKdTt48CTehpQsedt-mCU1X7Y5xf8uW0uHpe1yTPI8-fK-mgovq1K8eakg12QoHBbO5lSIn4kQXtoPTT55eX2YCGGdhlhxKSska_cVCw4HgyTybms1dJyTMdBRLHJng5UbJNEzzA6BlIzejwboAmLVd1xDTN9Ibqanu5HH55Y8NrAhEqujXDnjfBJdzdL5dDlvmHpzX1JALIxcwyT_0FqmwVTYjud2-nR_txjxNFStCODuIMv5LJ2lI4ITU-NbRqYko6NIT2tE0XK1pvJOgTkbTueisU_RE_n1Y7eDNt2cLDgWGhWs5szZWChnc0MqcLanUHZSH1RJrT2YwT1AW1lbxvm1ZoQeqmmpYXbSkJYBOlsf_tU4ONEOnnJq1efWBgkDqTkLbqyg4eeaAfkS3U1DOcuk4duv_f4BgYdG4Q[Traceability configuration graph]
` placeholders with
Kroki-rendered images. traceability:graph[] generates a GraphViz DOT
relationship graph for the enclosing item (or a specified item ID), colored
by role with labeled edges. traceability:graph-coverage[] generates a
Vega-Lite bar chart showing per-item or global coverage.
`
image::https://kroki.io/graphviz/svg/eJyFkUFLwzAYhu_7FaFeK0yY4pAKdTt48CTehpQsedt-mCU1X7Y5xf8uW0uHpe1yTPI8-fK-mgovq1K8eakg12QoHBbO5lSIn4kQXtoPTT55eX2YCGGdhlhxKSska_cVCw4HgyTybms1dJyTMdBRLHJng5UbJNEzzA6BlIzejwboAmLVd1xDTN9Ibqanu5HH55Y8NrAhEqujXDnjfBJdzdL5dDlvmHpzX1JALIxcwyT_0FqmwVTYjud2-nR_txjxNFStCODuIMv5LJ2lI4ITU-NbRqYko6NIT2tE0XK1pvJOgTkbTueisU_RE_n1Y7eDNt2cLDgWGhWs5szZWChnc0MqcLanUHZSH1RJrT2YwT1AW1lbxvm1ZoQeqmmpYXbSkJYBOlsf_tU4ONEOnnJq1efWBgkDqTkLbqyg4eeaAfkS3U1DOcuk4duv_f4BgYdG4Q[Traceability configuration graph]
` renders the traceability configuration as a GraphViz DOT diagram of roles and declared relations.
Rendering is opt-in
via the :traceability-graph: document attribute.
The registerMatricesInCatalog() method generates matrix files (HTML, CSV,
JSON) per component version during the contentClassified event and
registers them in the Antora content catalog as attachment-family files
via contentCatalog.addFile().
Registration happens before document
conversion, so attachment$traceability/… xrefs in nav files and pages
resolve without manual file copies.
Matrices are registered under every
module that has AsciiDoc content; a committed copy, if present, has its
contents refreshed in place.
DocumentParser
AsciiDoc content is parsed using regular expressions rather than an
Asciidoctor AST walker.
This makes the parser independent of Asciidoctor
API versions and testable with plain string inputs.
Verbatim blocks
(---- and …. fences) are pre-scanned and excluded to avoid
false positives from example code.
Known limitations (] in quoted
attribute values, indented macros) are handled with targeted fixes
(see ADR-002).
The parser’s internal pipeline has four distinct steps — each builds on the previous:
MatrixGenerator
The MatrixGenerator reads matrix definitions from configuration, queries the
graph for items by role, and computes per-cell coverage based on
coverageRelations.
Each cell shows related item IDs or — for no
relationships.
CSV output is direct; HTML output delegates to TemplateRenderer.
Coverage is calculated as overall, complete, partial, and missing counts.
The requirements-engineering preset defines pairwise matrices.
The HTML matrix renders a single per-row status column with values done, partial, and missing, derived from the row’s column coverage.
The per-row coverage percentage is no longer rendered as a separate column.
The top coverage summary (done/partial/missing counts plus overall percentage) is preserved.
CSV output is unchanged.
TemplateRenderer
The TemplateRenderer wraps Mustache for HTML matrix output.
Built-in templates
ship with the extension in src/templates/.
Users can specify a custom template
directory — the renderer falls back to built-in templates for any files not found
in the custom directory.
LinkResolver
The LinkResolver generates relative link paths from matrix output to component
root.
It normalizes sourceFile values by stripping the pages/ prefix and
.adoc extension, producing ../../page.html#ID links.
When an item carries a
module attribute, the module name is prepended to the path
(../../requirements/index.html#ID) so links resolve across modules in a
multi-module Antora site; the ROOT module is skipped because Antora serves it
directly under the component version directory.
With Antora’s
default indexify URL style, pages at the module root produce
pagename/index.html instead of pagename.html.
The relativePathPrefix
option allows configurable base paths, and the indexify option (default
true) controls whether root-level pages use index.html.
When no
LinkResolver is provided, items render as plain text without links.
Neo4jExporter
The Neo4jExporter serializes the traceability graph into nodes.csv and
relationships.csv with proper headers, comma delimiters, and escaped special
characters — importable via Neo4j’s LOAD CSV.
The import.cypher format
produces CREATE statements with :Item and role-specific labels.
All item
attributes are included as node properties.
The export is optional and requires
no Neo4j runtime dependency.
CLI Tool
The CLI is built on Commander with
subcommands for processing (process), matrix generation (matrix)
validation (validate), Neo4j export (export neo4j), statistics
(stats), preset management (preset), and ID allocation (next-id).
Global --config and --preset flags apply to all commands.
The CLI
operates standalone — it does not require an Antora build pipeline.
The GitHub Actions CI workflow (.github/workflows/ci.yml and pages.yml)
builds and deploys the example site to GitHub Pages.
The pages workflow
builds the Antora HTML site, generates PDFs and DOCX via @antora/pdf-extension
with Ruby/asciidoctor-pdf and pandoc (provisioned by devbox.json and Gemfile),
and deploys a combined output with the landing page at root and docs at
/docs/.
PDF and DOCX builds use assembler profiles with custom nav files
(nav-requirements.adoc, nav-architecture.adoc, nav-test-plan.adoc)
to produce separate documents per profile.
The existing single-document config
(antora-assembler-pdf.yml) is preserved as a standalone option.
A static landing/index.html page with Tailwind CSS loaded from CDN
serves as the project landing page at the GitHub Pages root (/).
The page uses conemso branding (teal palette #108193, Roboto font)
and targets two personas: developers/architects (docs-as-code) and
requirements engineers/PMs (traceability/compliance).
The Antora
documentation site remains at /docs/.
The CI workflow copies
landing/ assets to public/ during deployment.
No build step,
npm dependencies, or static site generator required.
The @antora/lunr-extension is configured with a custom indexing pass
that traverses non-heading elements with id attributes inside
<article class="doc">.
Each such element becomes a separate search
chunk with its id as the hash, enabling search results to link
directly to item anchors (e.g., #REQ-002).
Child elements with CSS
class title provide the chunk title; elements without a .title
child fall back to the id.
Heading elements are excluded from this
pass since they are already indexed by the existing heading-based
section chunking.
The example site demonstrates two architectural patterns.
First, config
extension: examples/traceability.yml extends the requirements-engineering
preset with a custom use_case role and leads_to relation, showing how
projects add domain-specific roles without modifying built-in presets.
Second,
the dashboard page (dashboard.adoc) embeds traceability:graph[] and
traceability:graph-coverage[] macros to provide visual coverage at a glance,
gated by the :traceability-graph: document attribute.
Both patterns serve as
reference implementations for users extending the extension to their own domains.
The bundled antora-vale-extension runs the Vale prose linter during contentClassified.
It streams each page and partial buffer to vale --path=<source> via stdin and parses the JSON output, reporting findings with source file and line references.
A configurable minLevel gates the build: findings at or above it fail the build, findings below are logged.
The extension is opt-in and requires the vale and asciidoctor executables on PATH, failing with an actionable message when either is missing.
A starter Vale style under src/vale/ encodes the machine-checkable parts of the documentation style guide.
Runtime View
The following sequence shows how an AsciiDoc file flows through the system during an Antora build:
| Participant | Role in processing |
|---|---|
Antora Build |
Triggers |
|
Event handler registration. Orchestrates the processing passes and matrix registration in the content catalog. Reads playbook config to determine preset, config path, output directory. |
|
Core API. Owns the graph, delegates to parser and generators. Same class used by CLI without Antora events. |
|
Regex-based extraction of |
|
In-memory graph. Validates relationships against config rules ( |
|
Queries graph for items by role, computes per-cell coverage using |
|
Mustache-based HTML rendering. Built-in templates with custom template directory support. |
|
Optional export. Serializes graph to CSV ( |
The extension runs four passes inside contentClassified.
The sequence diagram above shows the Antora-level flow; this diagram focuses on the internal pass structure.
| Pass | What happens | Scope | Why ordering matters |
|---|---|---|---|
Pass 1 |
|
Pages + partials |
Graph must be complete before Pass 3 can resolve cross-file `` references. Both pages and partials are processed in all three passes — partial content is inlined into pages and reaches the browser. |
Pass 2 |
|
Pages + partials |
Depends on complete graph from Pass 1 to enumerate relationships. Opt-in via |
Pass 2b |
|
Pages + partials |
Uses |
Pass 3 |
|
Pages + partials |
Needs complete graph (all sourceFiles known) from Pass 1. This is why Pass 1 processes all files before any macro expansion. |
Key timing characteristics:
-
Synchronous init:
AntoraTraceabilityExtensionconstructor initializes the traceability graph synchronously (config/preset loading is a synchronous file read) before any event handler is registered. This eliminates the race where acontentClassifiedevent could fire before the graph is ready. -
Single-pass processing:
process()call does both parsing and graph population in one call. No separate relationship processing pass needed. -
Lazy rendering: Templates are loaded and compiled on first use, cached thereafter.
The prepareFile() method computes file content, document attributes, and item block positions once per file, then feeds the same PreparedFile to all five macro-expansion methods.
This eliminates redundant buffer conversions and regex scans that were previously repeated in each method.
Concepts — Cross-cutting Concerns
ESM ("type": "module" in package.json) is used throughout the project.
This
choice enables modern import/export syntax, the node: protocol for built-in
modules, and alignment with browser JavaScript.
The tradeoff is that Antora’s
extension loader uses require(), requiring .cjs wrappers for integration
points (see ADR-001).
All modules accept their dependencies via constructor parameters.
There is no
service locator, DI container, or global state. TraceabilityGraph receives
ConfigLoader, MatrixGenerator receives TraceabilityGraph and ConfigLoader
and DocumentParser receives ConfigLoader optionally.
This makes every
component independently testable without mocking frameworks (see
ADR-004).
Architecture Decisions
This section captures the design decisions from openspec/changes/unified-item-architecture/design.md.
| Decision | Rationale | Alternatives Considered |
|---|---|---|
Single |
Simplifies mental model, enables user-defined roles without code changes, reduces parser duplication |
Keep separate macros (two ways to do things), role as positional parameter (less readable) |
Configuration-based roles and relations (YAML) |
Separates concerns, enables validation, allows presets, version-controllable |
Hardcoded roles (inflexible), JSON (less readable), JS config (more complex) |
Role-based relation validation at processing time |
Catches errors early, prevents invalid graphs, provides clear messages |
No validation (allows invalid graphs), warning only (users miss issues), runtime validation (too late) |
Mustache templates for HTML |
Separates presentation from logic, enables user customization, already working from prior work |
Embedded HTML generation (code duplication), other template engines (Mustache was already in place) |
Neo4j export (not custom query engine) |
Mature graph query capabilities, Cypher is industry standard, no need to build query language |
Custom DSL (high effort), GraphQL (not graph-optimized), Gremlin (less popular) |
Built-in presets for common domains |
Gives starting points, reduces config burden, demonstrates best practices |
No presets (must define from scratch), default roles (conflicts with flexibility goal) |
No default roles |
Forces explicit configuration, prevents domain model assumptions |
Default roles (simpler but less flexible), fallback behavior (complex transition logic) |
Unknown roles = warnings (not errors) |
Graceful degradation, partial config allowed, incremental role adoption |
Error on unknown (too strict), silent ignore (users miss config issues) |
Manual regex parsing over Asciidoctor.js extensions |
Simpler, version-independent, easier to test, works consistently across Asciidoctor.js versions |
Asciidoctor.js block processors (complex, version-dependent API) |
Config-driven matrix generation |
Users define which roles go on rows and columns, which relations count as coverage |
Hardcoded matrix types (req-impl, req-test, full) — inflexible for domain models with different roles |
In-memory source substitution for clickable links |
After processing, replace `` with Asciidoctor xrefs in the content catalog buffer. Two-pass approach ensures cross-file targets are resolved. No disk writes. |
HTML post-processing (PDF-incompatible), Asciidoctor inline macro extension (API dependency) |
Dedicated LinkResolver component |
Separates path resolution from matrix generation, makes link generation configurable and testable in isolation. Robust |
Link generation in MatrixGenerator (mixes concerns), template-level logic (Mustache cannot do complex path manipulation) |