Files
soleprint/soleprint/atlas2/docgen/docs/index.html
2026-09-14 06:13:22 -03:00

1116 lines
52 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>docgen</title>
<link rel="stylesheet" href="docs.css">
</head>
<body>
<div class="layout">
<nav class="sidebar">
<div class="sidebar-header">
<b>docgen</b>
<small>code &amp; data &rarr; documentation</small>
</div>
<nav>
<div class="group">Start</div>
<a href="#what">What it is</a>
<a href="#idea">The idea</a>
<a href="#quick">Five minutes</a>
<div class="group">Architecture</div>
<a href="#layers">The three concerns</a>
<a href="#book">The book</a>
<a href="#ir">The IR</a>
<a href="#shape">Shape decides the drawing</a>
<div class="group">Reading</div>
<a href="#extractors">Extractors</a>
<a href="#usage">Usage, not just the spec</a>
<div class="group">Narrowing</div>
<a href="#views">Views</a>
<div class="group">Writing</div>
<a href="#emitters">Emitters</a>
<a href="#explore">Explore</a>
<a href="#notebooks">Notebooks</a>
<a href="#style">Style &amp; colour</a>
<div class="group">Reference</div>
<a href="#standalone">Standalone</a>
<a href="#commands">Commands</a>
<a href="#deps">Dependencies</a>
<a href="#testing">Testing</a>
<a href="#limits">Limits &amp; non-goals</a>
</nav>
</nav>
<main class="content">
<h1>docgen</h1>
<p class="lede">
Turn source artifacts — a codebase, a database, an API spec, a recording of
real traffic — into one canonical graph format, then render that format to
whatever the audience needs. The point is not the diagram. The point is the
format in the middle.
</p>
<!-- ─────────────────────────────────────────────────────────────── -->
<h2 id="what">What it is</h2>
<p>
Eight demos under <code>semester/</code> draw their architecture with
Graphviz. Every one of them hand-writes a <code>.dot</code> file with the
palette inlined and commits the <code>.svg</code> beside it. Three different
dark palettes between them, eight answers to the same question, and every one
of those diagrams is out of date the moment somebody moves a file.
</p>
<p>
docgen is the one answer. It reads the source rather than being told about it,
so a diagram cannot drift from the thing it describes, and it separates
<em>what a graph is</em> from <em>how it looks</em> so one extraction feeds
a diagram, an index, a notebook and a browsable site without being redone.
</p>
<div class="note">
<b>Nothing here writes a parser or a graph algorithm.</b> Parsers are adopted
(<code>ast</code>, tree-sitter, SQLAlchemy reflection via modelgen), algorithms
are networkx's. What docgen owns is the adapters, the schema, the style tables
and the emitters — all small, and all the places where the value is that
<em>we</em> made the call.
</div>
<!-- ─────────────────────────────────────────────────────────────── -->
<h2 id="idea">The idea</h2>
<p>
N sources and M outputs need N&times;M converters if you join them directly,
or <strong>N+M</strong> if you put a hub in the middle. The hub is an
<em>intermediate representation</em> — the compiler term, and the same bargain:
both sides depend on the IR and neither on the other.
</p>
<p>
It is lossy on purpose. It throws away every token of syntax and keeps
<em>"a class named User inherits from Base"</em>. That is the part that is
worth versioning, worth diffing, and worth drawing.
</p>
<p>
The practical consequence is the thing to judge it on: <strong>adding a source
costs one extractor and every emitter works on it unchanged; adding an output
costs one emitter and every extractor feeds it unchanged.</strong> When the
OpenAPI reader was written it emitted schemas using the same vocabulary the
database reader uses — and the ER diagram drew an API's data model without
anyone teaching it what an API was.
</p>
<!-- ─────────────────────────────────────────────────────────────── -->
<h2 id="quick">Five minutes</h2>
<p>
One command, if you want the whole thing — a <a href="#book">book</a>: what
went in, every step, what came out, and a page to open.
</p>
<pre><code>make book SRC=/path/to/repo BOOK=out/book/mine
make check BOOK=out/book/mine</code></pre>
<pre><code> larder /path/to/repo — 45 files read, 2 failed, 12 packages
book 312 nodes · 244 edges · 26 external · 8 artifacts
ok 45 file(s) read produced 45 module(s)
open out/book/mine/site/index.html</code></pre>
<p>
Underneath it is three commands that compose, and each still works on its own.
That is the interface, and the book does not replace it:
</p>
<pre><code><span class="c"># 1. read something</span>
python3 -m docgen.extractors.python --root ../station/tools/histgen -o ir.json
<span class="c"># 2. narrow it to a useful view</span>
python3 -m docgen.ops ir.json --overview -o view.json
<span class="c"># 3. draw whatever its structure asks for</span>
python3 -m docgen.emitters auto view.json -o out/</code></pre>
<p>Or through the Makefile, which is a thin wrapper over exactly those:</p>
<pre><code>make ir SRC=/path/to/repo OUT=out <span class="c"># extract</span>
make explore OUT=out <span class="c"># the two-pane navigator</span>
make site OUT=out <span class="c"># a docs site with a sidebar</span>
make self <span class="c"># docgen's book of soleprint, then check it</span></code></pre>
<p>
Everything is offline and self-contained. No server, no CDN, no build step —
the outputs open over <code>file://</code>.
</p>
<!-- ─────────────────────────────────────────────────────────────── -->
<h2 id="layers">The three concerns</h2>
<p>
DOT collapses three separate questions into one file format, which is why a
hand-written <code>.dot</code> is never reusable: you cannot change the palette
without editing the structure, and you cannot change the structure without
re-deciding the layout. docgen keeps them apart.
</p>
<table>
<tr><th>concern</th><th>question</th><th>owner</th></tr>
<tr><td><b>structure</b></td><td>what the graph <em>is</em></td><td><code>ir/schema.json</code></td></tr>
<tr><td><b>meaning</b></td><td>what things <em>mean visually</em></td><td><code>style/*.json</code>, keyed on <code>kind</code></td></tr>
<tr><td><b>placement</b></td><td>where things <em>go</em></td><td>the emitter, and only there</td></tr>
</table>
<p>
An extractor has never heard of SVG, colours or layout. An emitter has never
heard of Python, <code>ast</code> or SQL. Both halves of that are checked by
parsing the source and looking at what it imports, because a rule nobody
enforces is a rule that lasts about a month.
</p>
<figure>
<a href="viewer.html?src=img/architecture.svg">
<img src="img/architecture.svg" alt="docgen's own module structure">
</a>
<figcaption>
docgen read by docgen. <code>extractors/</code> reaches only <code>ir</code>;
<code>emitters/</code> reaches <code>ir</code> and <code>style</code>;
<code>ir/</code> reaches nothing outside itself; <code>lab/</code> has no
edges at all. Click to open the viewer — then click again for actual size.
</figcaption>
</figure>
<!-- ─────────────────────────────────────────────────────────────── -->
<h2 id="book">The book</h2>
<p>
A <b>book</b> is one docgen operation, and it has a fixed shape: it begins by
saying what went in and ends by saying what came out. Everything in between is
an ordinary file that stands on its own.
</p>
<pre><code>larder ──► step ──► step ──► step ──► book
what each one usable what
came in by itself came out</code></pre>
<p>
The word comes from Atlas 1.0, where a book is a <em>larder</em> composed with a
pattern into something published and served. docgen had already built all three
parts under other names, so this is less an adoption than a renaming back.
</p>
<h3>Why both ends, rather than just the result</h3>
<p>
Because the two numbers are only worth having together. <em>"1,505 nodes"</em>
is not a fact about anything. <em>"225 files in, 1,505 nodes out, nothing
lost"</em> is. Before this, an extractor that read 45 of 47 files produced
exactly the same document as one that read all 47, and the diagram looked
complete either way — there was nowhere for the other two to be mentioned.
</p>
<div class="note warn">
<b>A clean diagram over an incomplete read is a lie by omission.</b> It is also
the failure mode the IR already guards against one level down: an unresolved
name becomes an <code>external</code> node rather than being dropped, because
silently losing a thing is worse than recording an unresolved one. The larder
measure is that same rule applied to the input as a whole.
</div>
<h3>The larder — what came in</h3>
<p>
Deliberately not called a bucket. A bucket is somewhere bytes sit; a larder is
<em>stocked from outside</em>, has an inventory, and goes stale. All three are
worth measuring, and they are what the measure records:
</p>
<pre><code>"larder": {
"kind": "python", <span class="c">// which extractor stocked it</span>
"identity": "../../station", <span class="c">// path, or a DSN with the password masked</span>
"unit": "file", <span class="c">// file | table | path | entry | document</span>
"seen": 47, <span class="c">// what the larder offered</span>
"read": 45, <span class="c">// seen - len(failed), derived</span>
"failed": [{"name": "a.py", "error": "syntax: line 3"}],
"extra": {"packages": 12}
}</code></pre>
<p>
<code>read</code> is derived and never stored. Stored, it invites the question
<em>"does that include the failures?"</em> and every reader answers it
differently; derived, there is nothing to get wrong — and
<code>ir/validate.py</code> fails a document whose arithmetic disagrees with
itself.
</p>
<p>
Failures are recorded <b>by name</b>, not counted. A count tells you a book is
incomplete; a name tells you which part of it to distrust.
</p>
<div class="note">
<b><code>identity</code> is the one field in docgen that could carry a secret</b>
— a database DSN has the password in it. It is masked at construction, and
<code>validate.py</code> then sweeps for the mask having worked, using its own
independent key list. A scrubber graded by its own word is not graded.
</div>
<h3>The book measure — what came out, and reconciled</h3>
<p>
Counts by kind, edges by kind, externals, and every artifact with its byte
count. On its own that is just a summary. What makes it a measure is that it is
<em>reconciled</em> against the larder:
</p>
<pre><code> larder ../../station — 45 files read, 2 failed, 12 packages
book 312 nodes · 244 edges · 26 external · 8 artifacts
ok 45 file(s) read produced 45 module(s)
ok 2 file(s) could not be read
ok 2 unreadable file(s) appear as 2 marked module(s)</code></pre>
<p>
The relation differs by source and is declared per extractor, because getting
it wrong gives a check that passes for the wrong reason. One file becomes one
module node — fewer means input was dropped. Four hundred HAR entries becoming
twelve endpoints is not a loss, it is the <em>point</em> of the capture. And a
file that failed to parse must still appear in the graph, carrying its error,
or the picture is smaller than the source and says nothing about it.
</p>
<p>
<code>python3 -m docgen.book</code> exits 1 when a reconciliation fails. The
book is still written — the evidence is the point — but a build that lost input
should fail a pipeline rather than pass quietly.
</p>
<h3>The notebook is the sequence, the web is the last step</h3>
<p>
These two rule what gets generated, and each for its own reason.
</p>
<p>
The <b>notebook</b> is not one artifact; it is the sequence. Its first cell is
the larder measure and its last cell is the book measure, which is what puts
the two ends in the <em>document</em> rather than only in the tooling. Between
them, one pair of cells per step: what the step did, and a cell that loads that
step's artifact and prints one fact about it. That is the "usable by
themselves" property made executable, and the test suite runs those cells.
</p>
<p>
The <b>web output</b> is last, so nothing depends on it, so it can be replaced
wholesale without touching anything upstream. That is exactly what lets it rule
the output without being a stable contract — the book measure is the promise,
and the page displaying it is free to change drastically and often. It is also
the artifact somebody definitely opens, which is why <em>"2 of 47 files could
not be read"</em> has to appear there, above the diagram rather than below it.
</p>
<div class="note">
<b>The spine is scaffolding, not a gate.</b> Running one step alone is still a
book, just a short one — <code>make ir</code> works exactly as it did. An
operation that cannot measure something says what it could not measure and
carries on. Gating would destroy the property that makes the intermediate
artifacts useful, which is the whole reason the sequence is worth having.
</div>
<h3>What a book looks like on disk</h3>
<pre><code>book/&lt;slug&gt;/
├── book.json <span class="c">both measures, the steps, artifacts with byte counts</span>
├── steps/ <span class="c">every intermediate — ir.json, view.json, graph.svg, …</span>
├── notebook.ipynb <span class="c">the sequence; first and last cells are the measures</span>
├── overlay.json <span class="c">hand-written, optional, re-applied every build</span>
├── checks.py <span class="c">this book's own assertions — optional</span>
└── site/ <span class="c">the web output, both measures at the top</span></code></pre>
<pre><code>make book SRC=../station BOOK=out/book/station
make check BOOK=out/book/station</code></pre>
<!-- ─────────────────────────────────────────────────────────────── -->
<h2 id="ir">The IR</h2>
<p>
Plain JSON. Three keys, and it has survived four domains without gaining a
fourth.
</p>
<pre><code>{
"meta": { "source": "python", "root": "app/", "schema_version": "1" },
"nodes": [ { "id": "app.models.User", "kind": "class", "label": "User",
"parent": "app.models",
"attrs": { "file": "app/models.py", "line": 12, "lines": 40 } } ],
"edges": [ { "source": "app.models.User", "target": "app.db.Base",
"kind": "inherits", "attrs": {} } ]
}</code></pre>
<table>
<tr><th>field</th><th>meaning</th></tr>
<tr><td><code>id</code></td>
<td>Fully qualified and <b>stable across runs</b>. Stability is what makes
two extractions from two commits diffable; without it a diff reports
noise and nobody trusts it.</td></tr>
<tr><td><code>kind</code></td>
<td>The hinge of the whole system, and the <b>only</b> field style and
layout may read. A small closed vocabulary per domain —
<code>module</code>/<code>class</code>/<code>function</code>,
<code>table</code>/<code>column</code>, <code>endpoint</code>,
<code>task</code>.</td></tr>
<tr><td><code>parent</code></td>
<td>Containment, and nothing else. A module contains a class. Relationships
are edges.</td></tr>
<tr><td><code>attrs</code></td>
<td>An open bag for whatever one domain cares about.
<code>file</code>/<code>line</code>/<code>lines</code> are what let a
box link to the line it came from, and what the minimap sizes by.</td></tr>
</table>
<div class="note">
<b>No visual information, ever.</b> If a field would change between a light and
a dark theme, it does not belong in the IR. <code>shape: "cylinder"</code> is
not a field — it is <code>kind: "datastore"</code> plus a style rule, and that
is exactly what lets the same IR render in a theme that has no cylinders. The
test suite sweeps every emitted document for colour-like keys.
</div>
<h3>Stdlib dataclasses, not Pydantic</h3>
<p>
The IR's whole value is being a plain document anything can open. A format that
needs a library installed to be read is an API, not a format. Validation is
therefore a function called at the boundary rather than a property of the type,
and it reads its field lists out of <code>schema.json</code> so the schema and
the dataclasses cannot drift apart.
</p>
<pre><code>python3 -m docgen.ir ir.json</code></pre>
<p>
It catches what a schema cannot: an edge naming a node that does not exist, a
containment cycle, a duplicate id, and a visual field smuggled into
<code>attrs</code>.
</p>
<!-- ─────────────────────────────────────────────────────────────── -->
<h2 id="shape">Shape decides the drawing</h2>
<p>
A diagram that fights its layout engine is usually the wrong <em>kind</em> of
diagram. The clearest evidence: the same 24-table database rendered
<b>32034&times;136</b> through Graphviz — a 235:1 strip — and
<b>1740&times;1860</b> through the ER emitter. Not because one engine is
better, but because a schema is a set of peer entities with references, and
laying it out in dependency ranks was never its shape.
</p>
<p>
So <code>ops.classify()</code> reads the structure and names the emitter,
with the reason attached — advice without a reason gets overridden the first
time it is inconvenient.
</p>
<table>
<tr><th>kind</th><th>drawn by</th><th>when</th></tr>
<tr><td><code>erd</code></td><td>cards in columns</td><td>entities with references</td></tr>
<tr><td><code>pipeline</code></td><td>ranks, left to right</td><td>a chain with fan-out — an Airflow DAG, a build</td></tr>
<tr><td><code>layered</code> / <code>tree</code></td><td>ranks, top down</td><td>ranks genuinely suit it</td></tr>
<tr><td><code>sheet</code></td><td>the index</td><td>one level is wider than ~20 — a strip in any engine</td></tr>
<tr><td><code>flat</code></td><td>the index</td><td>most nodes have no relationships: that is a list</td></tr>
</table>
<pre><code>$ python3 -m docgen.emitters auto view.json -o out/
sheet -&gt; index
109 nodes sit at one level; any layered engine draws that as a
strip. Split it, scope it, or read it as an index</code></pre>
<h3>Why twenty</h3>
<p>
Measured, one diagram per subsystem: at or under 20 nodes the output lands
around 1.6:1; at 70106 nodes about 7:1; at 261 nodes 14:1. Aspect ratio is a
property of the <em>graph</em>, not of the renderer — a layered engine puts one
dependency level in one row, so the widest level <em>is</em> the width.
</p>
<p>
Every Graphviz lever was tried before concluding this. <code>ratio=compress</code>
squashed a graph to an unreadable 1008&times;75; <code>rankdir=LR</code> merely
rotated a 14:1 into a 1:6; packing disconnected components gained nothing.
The fix was never a flag. It was to stop asking for one picture of everything —
which is what <a href="#explore">explore</a> does.
</p>
<!-- ─────────────────────────────────────────────────────────────── -->
<h2 id="extractors">Extractors</h2>
<p>
<strong>Deterministic parsing only. No model in the structural path.</strong>
A diagram built from an AST cannot be out of date with the code. A diagram
built from a model's reading of the code is wrong the moment the model has a
bad day — which is the problem this exists to fix.
</p>
<table>
<tr><th>reader</th><th>reads</th><th>gives</th></tr>
<tr><td><code>python</code></td><td>a tree of <code>.py</code></td>
<td>modules, classes, functions; <code>imports</code> and <code>inherits</code> edges</td></tr>
<tr><td><code>code</code> <span class="pill opt">tree-sitter</span></td><td>C#, TypeScript, TSX</td>
<td>namespaces, classes, interfaces, methods — <b>structure only</b></td></tr>
<tr><td><code>db</code></td><td>a graphgen-compatible <code>schema.json</code></td>
<td>tables, columns, foreign keys</td></tr>
<tr><td><code>openapi</code></td><td>an OpenAPI / Swagger document</td>
<td>endpoints and the shapes they carry</td></tr>
<tr><td><code>usage</code></td><td>a HAR recording</td>
<td>what was actually called, in what order</td></tr>
</table>
<h3>Two passes, because <code>ast</code> resolves nothing</h3>
<p>
Given <code>class User(Base)</code>, Python's <code>ast</code> hands over the
literal string <code>"Base"</code>. It has no idea that came from
<code>from .db import Base</code> three lines up. So pass one collects, per
module, what it defines and what it imports; pass two resolves local names to
fully qualified ids. The edge then points at <code>app.db.Base</code> — a real
node — rather than at a box called <code>Base</code> that means nothing.
</p>
<div class="note">
<b>Unresolved names become nodes, never nothing.</b> A third-party import or a
dynamically-built base becomes a node of <code>kind: "external"</code> and
<em>keeps its edge</em>. Dropping it would be the worse failure: the diagram
would look complete and have quietly lost a dependency. Gathered up, those
nodes are the project's real dependency surface.
</div>
<h3>C# and TypeScript</h3>
<p>
Handled by tree-sitter, which is why generics, nested types and a brace inside
a string are non-events rather than special cases. The test suite asserts that
last one specifically, because it is exactly where a hand-rolled scanner breaks.
</p>
<p>
This reader produces <strong>no edges</strong>. Resolving a C#
<code>using</code> to the thing it names is a different and much larger job,
and the consumer that needs this — the minimap — needs none of it. An
extractor that quietly produced half a dependency graph would be worse than one
producing none, because the half would look whole.
</p>
<!-- ─────────────────────────────────────────────────────────────── -->
<h2 id="usage">Usage, not just the spec</h2>
<p>
An OpenAPI document says what endpoints <em>are</em>. It does not say how to
use them — least of all when they are not RESTful, or when a GraphQL endpoint
sits alongside. So docgen also reads a <b>HAR</b>: the recording format that
browser devtools, mitmproxy, Charles and Insomnia all export.
</p>
<table>
<tr><th>what traffic knows</th><th>what a spec cannot</th></tr>
<tr><td>the <b>order</b> of calls</td><td>a spec is a set; usage is a sequence</td></tr>
<tr><td>which parameters are <b>always</b> sent</td><td>a spec lists twenty optional ones</td></tr>
<tr><td>which statuses <b>really</b> happen</td><td>the 422 everybody hits is in no document</td></tr>
<tr><td>endpoints not in the document</td><td>GraphQL operations, found by body shape and named</td></tr>
<tr><td>which id formats a route takes</td><td>numeric <em>and</em> uuid on one route</td></tr>
</table>
<div class="note warn">
<b>No credential and no payload value reaches the IR</b> — only field names and
types. A HAR is full of live bearer tokens and cookies, and a generated
document gets committed. The test suite plants a token in its fixture and fails
if it appears anywhere in the output.
</div>
<p>
Two limits, stated rather than glossed: path templating is a <em>guess</em>
(<code>attrs.observed_paths</code> keeps what was actually seen beside it), and
consecutive is not caused-by — the edge weight is what separates a habit from
an accident, and one recording will not tell you which.
</p>
<!-- ─────────────────────────────────────────────────────────────── -->
<h2 id="views">Views</h2>
<p>
The first real diagram out of this pipeline was a 3000px-wide strip: four
modules of actual content and sixty <code>sys</code>/<code>json</code>/<code>typing</code>
boxes as their peers. The emitter was correct and the picture was useless.
That is a <em>missing view</em>, not a broken renderer — and the fix belongs to
every consumer at once, because the index, the diagram and the diff all want
the same narrowing.
</p>
<pre><code>python3 -m docgen.ops ir.json --overview -o view.json
python3 -m docgen.ops ir.json --around docgen.ir --hops 2 -o view.json
python3 -m docgen.ops ir.json --split -o parts/
python3 -m docgen.ops ir.json --shape <span class="c"># what will this look like?</span></code></pre>
<p>
All of them are IR&rarr;IR, all composable, and each produces a document that
still validates. <code>--overview</code> is the default and dispatches on the
source: a codebase reduces to its modules and its outside dependencies, a
schema to its tables and their keys.
</p>
<div class="note">
<b>Edges are lifted when a view collapses detail, never dropped.</b> A class in
module A inheriting from a class in module B <em>is</em> a dependency of A on
B. Collapsing docgen to its packages once kept 8 of 77 edges — those pictures
were not simpler, they were <em>wrong</em>. Lifted edges carry a
<code>weight</code> saying how many they stand for.
</div>
<h3>Depth is the tempting knob and the wrong one</h3>
<p>
A directory without an <code>__init__.py</code> is not a package, so its
modules have no parent and sit at depth 0. soleprint has <b>173 such roots</b>,
and a depth-2 cut still held 566 functions and 142 classes. Selecting by
<code>kind</code> does not care how the directories happen to be arranged.
</p>
<!-- ─────────────────────────────────────────────────────────────── -->
<h2 id="emitters">Emitters</h2>
<table>
<tr><th>emitter</th><th>output</th><th>audience</th></tr>
<tr><td><code>index</code></td><td>markdown, sidebar JSON</td>
<td><b>anyone</b> — no graph literacy required</td></tr>
<tr><td><code>dot</code></td><td>DOT &rarr; Graphviz &rarr; SVG</td><td>dependency structure</td></tr>
<tr><td><code>erd</code></td><td>SVG, written directly</td><td>a schema, as cards</td></tr>
<tr><td><code>minimap</code></td><td>SVG, written directly</td><td>what is where, at a glance</td></tr>
<tr><td><code>notebook</code></td><td><code>.ipynb</code></td><td>a runnable walkthrough</td></tr>
<tr><td><code>site</code></td><td>a static docs site</td><td>reading</td></tr>
<tr><td><code>explore</code></td><td>a two-pane navigator</td><td>finding your way</td></tr>
<tr><td><code>auto</code></td><td>whichever of the above fits</td><td>not having to choose</td></tr>
</table>
<p>
<strong>The non-visual ones matter most for reach.</strong> A sorted, described
list of what exists is readable by someone who will never open a diagram, and
it also reports the dependency surface and any file that failed to parse.
It is built second, not last — it is what proves the IR is not secretly
diagram-shaped.
</p>
<h3>ERD — and where the layout came from</h3>
<p>
Not invented here. <code>station/tools/graphgen/templates/index.html</code>,
the Supabase-style schema explorer already in this repo, had solved it:
</p>
<pre><code>const cols = Math.max(2, Math.ceil(Math.sqrt(sorted.length * 1.2)));</code></pre>
<p>
<strong>Columns from the square root of the table count.</strong> The aspect
ratio is <em>chosen</em> rather than emergent, so the result stays near-square
at 4 tables or 400. That is the one thing a rank-based engine cannot offer.
Three more things it gets right: a table is a <em>card</em> with its columns;
an edge leaves the column holding the key and lands on the target's primary
key; and the geometry is computed rather than measured, so it renders
identically on any machine.
</p>
<figure>
<a href="viewer.html?src=img/erd.svg">
<img src="img/erd.svg" alt="an entity-relationship diagram">
</a>
<figcaption>A schema from the sample room. Same emitter, same style file as
every other diagram here.</figcaption>
</figure>
<h3>Minimap</h3>
<p>
Sublime's minimap shrinks the <em>characters</em>. This draws the
<em>structure</em> at full scale: one file is a column, one line is a fixed
number of pixels, every construct a block sized by its span and coloured by
what it is. No text inside a block — the shape is the message.
</p>
<p>
The claim is that the pattern comes from the colours alone, so nesting is drawn
by inset rather than by hue. On soleprint you can see that modelgen is
class-based, histgen is function-based and tester is mixed, without reading a
line.
</p>
<figure>
<a href="viewer.html?src=img/minimap.svg">
<img src="img/minimap.svg" alt="a structural minimap of docgen">
</a>
<figcaption>docgen's own files. Blue class, amber interface, green function,
dark for everything that is not a declaration — imports, constants, prose.</figcaption>
</figure>
<!-- ─────────────────────────────────────────────────────────────── -->
<h2 id="explore">Explore</h2>
<p>
The minimap on its own shows shape and no meaning: a block says "a 30-line
class", not <em>which</em> class or what it touches. So it is not the artifact.
It is the <strong>selector</strong>.
</p>
<pre><code>make explore OUT=out <span class="c"># then open out/explore/explore.html</span></code></pre>
<div class="cols">
<div>
<h4>Left — navigate</h4>
<p>The whole thing at once. Scan by colour, click a block.</p>
</div>
<div>
<h4>Right — explore</h4>
<p>What that is, what it reaches, what reaches it, and the neighbourhood
drawn small enough to read. Every neighbour is a link, so you walk
outward from wherever you started.</p>
</div>
</div>
<div class="note">
<b>This is what retires the 14:1 sheet.</b> The whole graph is never drawn. The
overview pane carries the overview, and only the neighbourhood of a selection
is rendered — a handful of nodes, which lays out fine every time. Overview and
detail stop competing for one picture.
</div>
<p>
The same split applies to a database: every table at once with <em>no column
detail</em>, then click one to get its columns plus the tables its keys reach.
The two differ exactly where they should — a module's neighbourhood
deliberately leaves its contents out, because those are the hundred functions
that made the sheet unreadable, while a table's brings them in, because a table
without its columns is not a table.
</p>
<h3>The selection basket</h3>
<p>
Shift-click accumulates blocks. The basket is a copyable list of paths with a
line count — enough to hand to <code>distill</code>, and enough to see that the
selection got too big <em>before</em> spending the context on it. Navigating a
tree quickly in order to decide what to feed a model is a real use, and this is
the part that serves it.
</p>
<!-- ─────────────────────────────────────────────────────────────── -->
<h2 id="notebooks">Notebooks</h2>
<p>
A notebook is normally a source file somebody confects by hand: prose, code and
stored output braided together, diffing badly, drifting from whatever it
documents the moment either moves, with no way to tell by looking.
</p>
<p>
Here a notebook is a <strong>build artifact</strong>. The source is the OpenAPI
document — the same file the server is built from — and the notebook is
regenerated from it. Nobody edits the <code>.ipynb</code>, the same way nobody
edits a <code>.o</code>. "Is this document current" stops being a question
about somebody's diligence and becomes a question about whether the build ran.
</p>
<blockquote>
This is the disagreement with jupytext. Jupytext fixes the <em>diffing</em>
it makes a notebook editable as text — and leaves the actual problem: you still
hand-author it, so it still rots.
</blockquote>
<h3>Generated base, hand-written overlay</h3>
<p>
Generation alone gives a document that is never stale and never says anything a
parser could not work out. Hand-authoring alone gives insight and a document
that rots. Two files is the only arrangement that gets both:
</p>
<pre><code>IR ──► spec ──(+ overlay)──► merged spec ──► .ipynb
generated hand-written merged emitted</code></pre>
<p>
The <b>spec</b> is an ordered list of steps with no Jupyter in it — a Swagger
for notebooks, readable and diffable. The <b>overlay</b> is the only file
anyone edits, and it is re-applied on every build. It can
<code>annotate</code>, <code>replace</code>, <code>insert</code>,
<code>drop</code> and <code>order</code>.
</p>
<p>
<code>replace</code> is the one that matters. It is how real usage gets into a
document that a spec could not describe — the call that is always made with
<code>status=available</code>, the GraphQL endpoint that is not in the OpenAPI
file at all — and it keeps working unchanged once a usage recording supplies
the same facts automatically.
</p>
<div class="note">
Three properties hold it together: regenerating <b>re-applies the overlay
byte-for-byte</b>; when the base moves underneath it the mismatch is
<b>reported, never silently dropped</b>; and extraction works with the overlay
<b>absent</b> — it is an addition, never a dependency.
</div>
<pre><code>python3 -m docgen.emitters notebook ir.json --scaffold overlay.json
python3 -m docgen.emitters notebook ir.json --overlay overlay.json -o walkthrough.ipynb</code></pre>
<!-- ─────────────────────────────────────────────────────────────── -->
<h2 id="style">Style &amp; colour</h2>
<p>
A style rule names a <strong>slot</strong>, never a colour.
<code>"border": "atlas"</code> is the rule; a theme binds <code>atlas</code> to
<code>#43A047</code> in print and <code>#15803d</code> on the docs site.
</p>
<p>
That indirection is the whole point. <code>common/theme/tokens.css</code>,
<code>docs/graphs/themes/*.gvpr</code> and <code>style/lucid.json</code> use
the same slot names, so a diagram and the page around it match by construction
— which is the rule <code>docs/graphs/README.md</code> already states. The dark
theme's <code>artery</code>, <code>atlas</code> and <code>station</code> slots
are exactly the <code>--system-accent</code> values the three system pages set,
and the test suite fails if they drift apart.
</p>
<p>
Dark is the default, because a generated diagram lands in a dark docs page far
more often than in a document. <code>--theme lucid</code> gives the print
palette — and gives it to the <em>page</em> as well as the diagram, since both
are baked from the same slots.
</p>
<div class="note">
An unknown <code>kind</code> falls back to <code>default</code> rather than
crashing. That matters more than it sounds: a new extractor with a new
vocabulary renders plainly and legibly on day one, instead of requiring
somebody to write a style file before they can see anything.
</div>
<h3>Where DOT stops</h3>
<p>
The emitter writes what DOT expresses natively and stops at the boundary rather
than growing machinery. The limits are recorded in the style file itself: a
cluster has a label and a fill but not a header bar;
<code>stroke-dasharray</code> is not parameterised, so <code>4,4</code> and
<code>5,5</code> collapse; <code>rounded</code> is binary, so 4px and 6px are
identical. Those mark where a richer emitter would begin — and the style file
carries the full specification regardless, so that emitter needs no
re-authoring.
</p>
<p>
One limit <em>was</em> worth solving: DOT cannot use a cluster as an edge
endpoint, so every module-to-module import silently vanished. The native answer
is <code>compound=true</code> with <code>lhead</code>/<code>ltail</code> — draw
between a representative leaf and clip the line at the cluster border.
</p>
<!-- ─────────────────────────────────────────────────────────────── -->
<h2 id="standalone">Standalone</h2>
<p>
Copy the <code>docgen/</code> folder anywhere and it works. The Makefile
derives its own package name from where it sits, so it can be renamed too, and
everything else resolves inside the directory.
</p>
<pre><code>cp -r docgen /somewhere/else
cd /somewhere/else/docgen
make doctor <span class="c"># what this machine has</span>
make check <span class="c"># the suite, from the copy</span>
make book SRC=/path/to/any/repo BOOK=out/book/theirs</code></pre>
<h3>One seam, and it is optional</h3>
<p>
Exactly one capability needs more than the folder: reading an <b>OpenAPI</b>
document goes through <code>station/tools/modelgen</code>, which parses the
spec and resolves <code>$ref</code>. It is deliberately not reimplemented here
— a second OpenAPI reader in one repo is two things to keep correct.
</p>
<p>
So if you are using docgen standalone but keeping the repo alongside as
reference, point at it:
</p>
<pre><code>export DOCGEN_REFERENCE=/path/to/repo
make doctor
<span class="c"># reference: /path/to/repo (from $DOCGEN_REFERENCE)</span></code></pre>
<p>
Resolution is <code>$DOCGEN_REFERENCE</code> first, then walking up from the
package — so in place it needs no configuration, and an explicit path wins
when set. Without it, the four other extractors and every emitter work
unchanged; the OpenAPI reader reports what to set, and the suite
<em>skips</em> rather than fails.
</p>
<div class="note">
<b>An env var rather than a config file</b>, because there is one setting and
it is a path. A config file for one path is a file to find, parse, document and
validate, and the first question anyone asks of it is "where does it live" —
which is the same question again.
</div>
<h3>It is asserted, not asserted-in-prose</h3>
<p>
A standalone claim decays the moment somebody adds a convenient import, and it
decays <em>silently</em>, because the suite still passes inside the repo. So
the suite reads its own source:
</p>
<ul>
<li><b>Imports are the stdlib, docgen itself, and a short allow-list</b>
tree-sitter, lxml, yaml, networkx, modelgen, each optional and each with a
reason. A new name is a new dependency in a folder meant to be copied.</li>
<li><b>Only <code>extractors/openapi.py</code> imports modelgen.</b> One seam
is a seam; two is a dependency.</li>
<li><b>Only <code>reference.py</code> knows the repo layout</b>, so pointing
docgen elsewhere is one change rather than a search.</li>
<li><b>A book writes only inside its own output directory</b> — nothing is
left in the tree being read.</li>
</ul>
<p>
The folder was also literally copied to <code>/tmp</code> and run, which is
how the one real bug here was found: a check asserting the reference repo is
reachable, correct in place and wrong the moment there was nothing above. It
now reports which case applies instead of assuming one.
</p>
<table>
<tr><th>context</th><th>checks</th><th>skipped</th></tr>
<tr><td>in the repo</td><td>250</td><td>tree-sitter (259 with it)</td></tr>
<tr><td>copied out</td><td>242</td><td>tree-sitter, OpenAPI, the in-place case</td></tr>
<tr><td>copied out, <code>DOCGEN_REFERENCE</code> set</td><td>249</td>
<td>tree-sitter, the in-place case</td></tr>
</table>
<!-- ─────────────────────────────────────────────────────────────── -->
<h2 id="commands">Commands</h2>
<h3>Make</h3>
<table>
<tr><th>target</th><th>does</th></tr>
<tr><td><code>make book SRC=…</code></td><td><b>one whole operation</b>, measured at both ends</td></tr>
<tr><td><code>make check</code></td><td>docgen's own suite, offline, nothing installed</td></tr>
<tr><td><code>make check BOOK=…</code></td><td>one book's own level — generated and custom</td></tr>
<tr><td><code>make doctor</code></td><td>what this machine has and what it is missing</td></tr>
<tr><td><code>make ir SRC=…</code></td><td>extract Python into <code>OUT/ir.json</code></td></tr>
<tr><td><code>make code SRC=…</code></td><td>extract C#/TypeScript <span class="pill opt">tree-sitter</span></td></tr>
<tr><td><code>make db SCHEMA=…</code></td><td>extract a database schema</td></tr>
<tr><td><code>make view</code></td><td>the default view for that source type</td></tr>
<tr><td><code>make graph</code></td><td>draw whatever the structure asks for</td></tr>
<tr><td><code>make index</code></td><td>markdown index and sidebar JSON</td></tr>
<tr><td><code>make minimap</code></td><td>what is where, read from the colours</td></tr>
<tr><td><code>make explore</code></td><td>the two-pane navigator</td></tr>
<tr><td><code>make site</code></td><td>a self-contained docs site</td></tr>
<tr><td><code>make self</code></td><td>docgen's book of soleprint, then check it</td></tr>
</table>
<p>
Variables: <code>SRC</code>, <code>OUT</code>, <code>SCHEMA</code>,
<code>OPENAPI</code>, <code>HAR</code>, <code>BOOK</code>, <code>SLUG</code>,
<code>READER</code>, <code>OVERLAY</code>, <code>STYLE</code>,
<code>THEME</code>, <code>SCALE</code>, <code>DEPTH</code>, <code>PY</code>.
The Makefile derives its own package name from where it sits, so the folder can
be copied anywhere and renamed and still work.
</p>
<p>
<code>READER</code> rather than <code>LANG</code> because <code>LANG</code> is
the shell's locale variable, so <code>?=</code> inherits
<code>en_US.UTF-8</code> from the environment and the argument is rejected.
Every target above is one step of a book and still works alone — that is the
property the spine exists to preserve, not to replace.
</p>
<h3>Modules</h3>
<pre><code>python3 -m docgen.book --root SRC -o out/book/slug <span class="c"># the whole operation</span>
python3 -m docgen.book.checks out/book/slug <span class="c"># that book's level</span>
python3 -m docgen.extractors.python --root SRC -o ir.json
python3 -m docgen.extractors code --root SRC -o ir.json
python3 -m docgen.extractors db --schema schema.json -o ir.json
python3 -m docgen.extractors openapi --spec spec.yaml -o ir.json
python3 -m docgen.extractors usage --har session.har -o ir.json
python3 -m docgen.ir ir.json <span class="c"># validate</span>
python3 -m docgen.ops ir.json --overview -o view.json
python3 -m docgen.emitters auto view.json -o out/
python3 -m docgen.emitters index ir.json -o index.md
python3 -m docgen.emitters dot view.json -o graph.svg --theme lucid
python3 -m docgen.emitters erd ir.json -o schema.svg
python3 -m docgen.emitters minimap ir.json -o map.svg --scale 0.5
python3 -m docgen.emitters notebook ir.json -o book.ipynb --overlay overlay.json
python3 -m docgen.emitters site view.json -o site/
python3 -m docgen.emitters explore ir.json -o explore/</code></pre>
<!-- ─────────────────────────────────────────────────────────────── -->
<h2 id="deps">Dependencies</h2>
<p>
Everything below is optional. The stdlib covers the whole structural path — see <a href="#standalone">standalone</a>
for the one seam out of the folder.
</p>
<p>
The core is <strong>standard library only</strong>. Everything else is optional
and reported by <code>make doctor</code>; when something is missing you lose
exactly one capability and get told what to install.
</p>
<table>
<tr><th>needs</th><th>for</th><th>without it</th></tr>
<tr><td><code>graphviz</code> (binary)</td><td>rendering DOT to SVG</td>
<td>ERD, minimap, index and notebooks still work</td></tr>
<tr><td><code>tree_sitter</code> + grammars</td><td>C#, TypeScript, TSX</td>
<td>Python only</td></tr>
<tr><td><code>networkx</code></td><td><code>lab/</code> experiments</td>
<td>nothing — nothing depends on it yet</td></tr>
<tr><td><code>node</code></td><td>testing the browser pages</td>
<td>those checks skip</td></tr>
<tr><td><code>psql</code></td><td>the <code>lab/</code> schema probe</td>
<td>use modelgen's <code>from-db</code> instead</td></tr>
</table>
<div class="note">
<b><code>lab/</code> is where a dependency gets tried before anything depends
on it.</b> Nothing in <code>ir/</code>, <code>extractors/</code>,
<code>ops/</code> or <code>emitters/</code> may import from it. When an
experiment earns its place it graduates into <code>ops/</code> behind an
IR&rarr;IR signature, and <em>then</em> the dependency is declared.
</div>
<!-- ─────────────────────────────────────────────────────────────── -->
<h2 id="testing">Testing</h2>
<p>
Three levels, and they differ by <em>what they assert about</em>. The
distinction decides what a failure means, which is why it is worth keeping:
</p>
<table>
<tr><th>command</th><th>asks about</th><th>fails?</th></tr>
<tr><td><code>make doctor</code></td><td>the machine — what is installed</td>
<td>never; it reports</td></tr>
<tr><td><code>make check</code></td><td>docgen — 259 checks</td>
<td>exit 1</td></tr>
<tr><td><code>make check BOOK=&lt;dir&gt;</code></td><td>that one book</td>
<td>exit 1</td></tr>
</table>
<p>
All 259 with both optional dependencies installed, 244 with neither — the suite
skips rather than fails when tree-sitter, lxml or the OpenAPI reader is absent.
See <a href="#standalone">standalone</a> for the counts outside the repo.
</p>
<h3>The book level, where custom checks live</h3>
<p>
The third level is the one that reaches a project docgen has never seen, and it
is where <b>framework and hand-written checks live together</b>. The generated
half is the spine's own assertions, identical for every book: both measures
present, the reconciliation holding, every artifact where the ledger says it
is, the notebook still executing. The custom half goes in the book's own
<code>checks.py</code> and uses the same helpers, so a project's line and a
framework line read identically and fail identically.
</p>
<pre><code><span class="c"># out/book/station/checks.py</span>
<span class="k">def</span> checks(book, check, note, skip):
note("what this project will not give up")
<span class="c"># Payments moved once already and the move broke three dashboards.</span>
<span class="c"># If it is not here, something renamed it again.</span>
check("payments is still a module", True,
any(n["id"] == "app.payments" <span class="k">for</span> n <span class="k">in</span> book.ir["nodes"]))</code></pre>
<p>
Same split as the notebook's base and overlay, for the same reason: generation
alone cannot know what <em>this</em> project cares about, and hand-authoring
alone rots.
</p>
<div class="note">
<b>Each check is one decision that has already been made, with the reason above
it.</b> Not coverage, and deliberately not an exhaustive sweep. A rule without
its reason gets overridden the first time it is inconvenient, so failing a
check should read as <em>"you are about to undo this"</em> rather than
<em>"something broke"</em>. The idiom is carried from rig's
<code>ctrl/selftest.sh</code>, which is where the three-level split comes from.
</div>
<h3>The four that are the design</h3>
<p>
Of docgen's own checks, four assert the <em>architecture</em> rather than guard
a regression, and they are the ones to keep if anything is ever cut:
</p>
<ul>
<li><b>No visual field reaches the IR</b> — extractors cannot decide appearance.</li>
<li><b>No emitter reads a source file</b> — the layering, checked from the other side by parsing imports.</li>
<li><b>Style names slots, not colours</b> — one colour language rather than three.</li>
<li><b>Ids are stable across runs</b> — without it, diffing is noise.</li>
</ul>
<div class="note">
<b>Golden tests go on the IR, never on the SVG.</b> Graphviz measures label
text with the host's fonts to size nodes, so identical input produces different
geometry on a machine with different fontconfig. The IR is deterministic; the
SVG is not. Pinning the wrong one gives a suite that fails on somebody else's
laptop for no reason anyone can act on.
</div>
<p>
The browser pages are JavaScript, so they are tested as JavaScript: a stub DOM
under <code>node</code> drives the viewer's zoom and 1:1 toggle, and the
explorer's select-and-walk. Both skip cleanly where node is absent.
</p>
<p>
Self-hosting is the honest end-to-end check, and it is where the real bugs came
from — two name-resolution faults and a duplicate-id crash that no fixture had
reached. <code>make self</code> builds docgen's book <em>of soleprint</em> and
then runs that book's own level against it, so the two ends have to reconcile
on 225 real files. If the index does not read like the system, something is
wrong.
</p>
<!-- ─────────────────────────────────────────────────────────────── -->
<h2 id="limits">Limits &amp; non-goals</h2>
<p>Things deliberately not done, with the reason, so they are not re-litigated:</p>
<ul>
<li><b>No layout engine.</b> No positioning, no <code>neato -n2</code>, no ELK.
Aspect ratio was solved by choosing the right emitter and by not drawing
everything at once.</li>
<li><b>No <code>calls</code> edges.</b> Resolving <code>self.foo()</code> needs
type inference, and a call graph that is quietly 60% right is worse than
none because it reads as authoritative.</li>
<li><b>No edges from the C#/TypeScript reader.</b> Structure only — half a
dependency graph would look whole.</li>
<li><b>No model in the structural path.</b> Annotation — summarising a module,
naming a cluster — is a later layer, cached to its own file keyed by node
id, merged at emit time, and extraction must work with it absent.</li>
<li><b>No configuration knobs</b> until two real consumers disagree.</li>
</ul>
<p>Known gaps, stated plainly:</p>
<ul>
<li>The C# reader is verified against a written fixture, <b>not a real
repository</b>. That is the next check that matters.</li>
<li>The diff emitter is not built. Stable ids exist to make it possible, and
two IRs from two commits is the cheapest useful thing left.</li>
<li>Minimap blocks carry no names. Rendering them legible only at 1:1, through
the viewer, is the other half of reading a file without reading every line.</li>
</ul>
</main>
</div>
<script>
// Highlight the section being read. No dependency, no build step.
var links = [].slice.call(document.querySelectorAll('.sidebar a[href^="#"]'));
var byId = {};
links.forEach(function (a) { byId[a.getAttribute('href').slice(1)] = a; });
var observer = new IntersectionObserver(function (entries) {
entries.forEach(function (entry) {
var a = byId[entry.target.id];
if (a && entry.isIntersecting) {
links.forEach(function (l) { l.classList.remove('active'); });
a.classList.add('active');
}
});
}, { rootMargin: '-8% 0px -82% 0px' });
document.querySelectorAll('h2[id]').forEach(function (h) { observer.observe(h); });
</script>
</body>
</html>