forma-arch
v0.12.0
Published
Forma — present your architecture instead of slides. An interactive, stack-agnostic C4 explorer (context → container → component → leaf) generated from your code and kept true to it by a deterministic drift check.
Downloads
2,781
Maintainers
Readme
Forma
Present your architecture instead of slides. Forma turns any codebase into an interactive, stack-agnostic C4 explorer — big-picture → drill-down to the leaf — generated from the code and kept true to it by a deterministic drift check. No more slide decks that lie the day after you draw them.
Forma is the companion to arbiter: arbiter governs the process, Forma shows the system and
guarantees the picture matches reality.
▶ Live demo — not this repo. It is haben, a
private 53-package Go application, generated by forma at haben commit 4c9b6880: six domains over
53 packages, 193 edges read from real import blocks, and box text quoted from that repo's own
feature matrix. Its five-checkpoint architecture timeline is governed by haben's current product
boundary: the 63-node AS-IS baseline grows cumulatively to 66 nodes and 196 edges, while the final
operational checkpoint honestly reports no architecture change. A tool proving itself on its own
28 nodes proves nothing you care about.
What you are looking at is checked, not asserted: at that commit node scripts/presentable.mjs
exits 0 on the model — every level under 24 boxes, every box carrying prose rather than a file
count, every level with more than one box drawing at least one arrow — and forma check exits 0
against haben's source at the same commit. The curation that turns 53 flat packages into six
domains and maps the governed boundary to typed timeline patches is one human decision, checked in
at docs/demo/curate.mjs with the commands to rebuild it.
The one thing the demo does not do is regenerate itself: haben is private, so Pages publishes a
snapshot committed from a local run rather than cloning it in CI. Nothing automated will notice
if it goes stale. Forma's own model lives in docs/architecture/ and is drift-checked on every
push.
Why
A hand-drawn architecture diagram is stale the moment code changes. Forma walks your source for the real structure, infers relationships from cross-references to exported symbol names (heuristic, additive), and fails a check when the model and the code disagree. What you present is what actually exists.
Where the language declares its architecture, forma reads the declaration instead of guessing. On
Go the container and the leaf are the package (any directory with a non-test .go, however
deeply nested), _test.go files are not architecture, and every edge comes from an import block —
so the direction is right by construction. Every other stack uses the heuristic above.
Install
npx forma-arch <command> # or: npm i -D forma-archCommands
| Command | What it does |
|---|---|
| forma init | Seed docs/architecture/c4-topology.json from your source dirs (best-effort; then curate) |
| forma gen | Walk src/ leaves + derive container edges from cross-references; fill box text from docstrings/READMEs; group flat containers into components → c4-model.json |
| forma check | Deterministic drift check — fails if the model no longer matches the code |
| forma doc | Project the arc42 scaffold (ARCHITECTURE.scaffold.md), or --attach <file> to inject a governed block into an existing doc |
| forma serve | Open the live explorer at http://localhost:4173 |
| forma verify | Refresh status from live GitHub issues through your gh CLI — the only networked command |
Box text comes from your docs. gen fills each box with the module's docstring (Python """…""", JS/TS leading block), else the directory README.md, else a mapped arc42 section — so the explorer shows meaning, not a list of symbols. On a flat directory of many foo_* files it also synthesizes a component layer, described from its children's docs (--no-cluster to disable; --cluster-min <n> = leaves before a container is clustered, default 8; --group-min <n> = files sharing a prefix before they become a component, default 3).
Above the leaf, your feature matrix outranks the code. A docstring is the right answer for one file and the wrong one for a whole container: a stakeholder does not ask which docstring the first file inside it has, they ask what that part of the product does for the user — and in a governed repo that sentence is already written in a capability table. forma init finds those tables and lists them under docSources; gen joins each row to the nodes its code references name, and quotes the row verbatim (descSource: "docmap"). Nothing is composed or paraphrased. A node named by more than three rows is not described by the matrix but merely touched by it, so it yields nothing and the code chain runs instead.
"docSources": ["docs/FEATURE_MATRIX.md",
{ "path": "docs/spec.md", "describe": "capability", "ref": "code_ref", "status": "status" }]Column roles are detected by header name (capability/feature/description…, code_ref/path/module…, status/state) — name them explicitly when yours differ. Auto-detection additionally requires a status column, because "feature + file" is also the shape of a refactor plan and a task line does not belong in a stakeholder's box; a source you list by hand is trusted as written.
Progress can be generated, not only hand-written. Where those rows carry a status, gen derives the node's status2 and completion from them — one of two capabilities shipped is in-progress at 50%, and verify.source names the document and the tally. This is derived, so it is never trusted: forma check re-reads the document and fails if the committed model claims a number the document no longer supports, exactly as it re-walks src/ for structure. A node no document names stays unknown — the honest blank, not a made-up zero.
Programme state is curated, not guessed. Code shows what exists, never how far along it is. Drop a docs/architecture/c4-status.json (--status <path> to move it) and gen decorates nodes by id with status2, completion, statusWord, current, target, verify, issues — never func, which belongs to the docs. gen validates the form (ids resolve, fields known, enums and issue numbers well-shaped) and never the prose; forma check fails if the overlay decorates a node the model no longer has.
{ "nodes": { "engine": { "status2": "in-progress", "completion": 60, "statusWord": "v2 in progress",
"current": "Live on ACA: RAG + citations. Hardening this week.",
"target": "Multi-surface substrate with client-ready output.",
"verify": { "source": "ADR-040 on main" }, "issues": ["#534"] } } }The overlay is the authority: every field it sets wins over anything derived, and check stops re-deriving that field. You do not have to hand-edit that JSON — forma gen --status-apply <file> merges {"nodes":{"<id>":{…}}} into it, validating every patch before it touches disk, so a rejected fill leaves the committed overlay untouched. It is the state counterpart of --enrich-apply, for the fields no document can supply. To describe one box by hand without a table, put the sentence in descriptions, keyed "<containerId>/<node-name-without-extension>" — "core/alpha" for the leaf alpha.js in container core, "core/core" for the container itself, since a container is its own container. It outranks every other source.
Future architecture can be a governed timeline. Add an optional timeline to the curated
c4-topology.json when one binary target is not enough. The generated nodes and edges remain
the code-verified AS-IS; each checkpoint applies a compact typed patch over every checkpoint
before it. Forma never stores a second complete graph, never reads a board count as architecture,
and validates every cumulative state before replacing the last model.
{
"timeline": {
"source": "docs/architecture/FUTURE.md",
"checkpoints": [{
"id": "g1", "label": "G1 · UAT", "badge": "9 board · 1 P0",
"patch": {
"nodes": {
"add": [{ "node": {
"id": "mcp", "level": "container", "parent": "system",
"kind": "container", "name": "MCP channel", "status": "planned"
}, "change": "Add the MCP surface." }],
"update": [{ "id": "engine", "set": {
"current": "Serves HTTP and MCP through one core."
}, "change": "Extend the existing engine." }]
},
"edges": {
"add": [{ "edge": {
"from": "mcp", "to": "engine", "label": "delegates",
"estatus": "to-build"
}, "change": "Connect the new surface to the engine." }]
}
}
}]
}
}Node patches support add, non-structural update, and child-first remove; edge patches support
add, exact-match rewire, and remove. Every operation carries governed change prose; a live
node changed locally exposes it as “Change from previous”, while the graph accents changed nodes
and relations without keeping a second change log or drawing ghosts for removals. IDs, parents,
levels and kinds cannot be edited in place, ambiguous edge selectors fail, and a node cannot
disappear while children or incident relations survive. The source path must exist. AS-IS is
implicit and reserved; the last checkpoint is the target, so target is forbidden inside timeline
patches. With a timeline the viewer replaces CURRENT/TARGET with the checkpoints, supports
?checkpoint=g1, accents only the local patch, and keeps drill, layout, re-verification and export
working on the cumulative graph. A future checkpoint with no local patch says “no architecture
changes”; its display-only badge is not interpreted. Without a timeline, the legacy controls and
model are unchanged.
Curated state, verified against reality. forma verify asks your gh CLI for the state of every issue the model references (--gh-repo owner/repo, or meta.ghRepo in the topology), marks the nodes whose issues are closed as done, and prefixes their current with dated evidence. It touches state, never structure, and re-running it never stacks the evidence. It is opt-in and separate on purpose: gen and check never open a socket. In the served viewer, RE-VERIFY re-reads the model without losing your level, layout or mode.
One source of truth. forma doc --attach docs/architecture/arc42.md injects the generated diagrams/tables between <!-- forma:begin --> / <!-- forma:end --> markers in your existing doc; your prose lives outside them, and forma check fails if that block drifts. Attached files are recorded in source.attachedDocs, so the gate governs every doc you attach — not just the model's docPath; deleting the markers (or the file) from a registered doc fails the check rather than quietly un-governing it. That registry lives in c4-model.json, so commit the model — a lost model takes the registry with it. Where a repo lacks docs, forma gen --enrich can fill the remaining box holes with an LLM — opt-in, cached, never on the deterministic gate:
| --enricher | Use it when | Network |
|---|---|---|
| agent | An agent is driving forma. Writes enrich-plan.json with the holes; the agent writes the sentences (reading the sources if it wants) and gen --enrich-apply <file> applies them with the same cache and provenance. | none |
| anthropic | Headless / CI, with ANTHROPIC_API_KEY. | REST |
| openai | Same, with OPENAI_API_KEY. | REST |
| ollama | Sensitive repos: a local model, nothing leaves the machine. | localhost |
--enricher has no default: forma gen --enrich on its own fails loud and lists what it
accepts — the four above, plus echo, an offline stub the test suite uses and you should not. A
default provider is a silent choice about your network and your API keys — and the old default
(anthropic) meant that anyone without ANTHROPIC_API_KEY exported got a skip line, exit 0 and the
same empty boxes they ran --enrich to fill.
How it fits together
code ──► forma gen ──► c4-model.json ──┬──► c4-viewer.html (present / explore)
(single source) ├──► ARCHITECTURE.md (arc42, via forma doc)
└──► forma check (gate: model == code?)One source of truth (c4-model.json); two renderings (the interactive viewer and the arc42 doc);
one deterministic check that keeps them honest. The file contract is
lib/schema/c4-model.schema.json.
Working on forma itself? docs/ORIENTATION.md walks what each pass of
gen/check actually decides, with path:line, and audits which mechanisms nothing in the repo
currently feeds.
Model-agnostic by design
The engine is plain Node — no LLM required. Structure is auto-walked, relationships are derived from
code, the check is deterministic. The only human (or agent) step is curating the topology groupings
and writing the arc42 prose — and any model can do that against the documented JSON/Markdown
contract. The Claude skill in adapters/ is a thin wrapper, not the product.
Explore
The viewer is a live C4 map, not a static picture:
- Click any box to read its explanation — what it does, current state/target on legacy models, or the state and local change at the selected checkpoint — at every level, from context down to a leaf.
- Double-click a box (or its
[+] DRILL) to descend into it; BACK, the breadcrumb, orESCclimb back out. - Drag boxes to lay out the view your way; RESET LAYOUT restores the arrangement (your curated hints if the topology has them, the automatic one otherwise). To keep a layout, drag it, pick Export layout JSON, and paste the result under
"layout"in the topology —gencarries it intometa.layoutand the viewer pins those boxes, auto-arranging everything else clear of them. - Arrow labels are painted on the diagram while the level stays readable (≤14 arrows) and turn off above that; LABELS forces them on or off, and hovering an arrow always reveals its label.
- PRINT / EXPORT to SVG or PNG for docs and slides — exported arrows carry their labels.
- The breadcrumb names the C4 level you are on (
C4-L1 · CONTEXT→C4-L3 · COMPONENTS) and, when the level carries curated state, tallies it: a dot per status with its count plus mean completion, so a level reads as a programme board and not just a diagram. - Every level shrink-wraps its own content — a context with four boxes renders zoomed and dense instead of adrift in a fixed canvas.
Skins
The viewer ships with swappable skins (holo, blueprint) via a dropdown or ?skin=. Themes are
CSS variables; the engine is decoupled from the look.
License
Apache-2.0. Not affiliated with C4 or arc42 — see NOTICE.
