1116 lines
52 KiB
HTML
1116 lines
52 KiB
HTML
<!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 & data → 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 & 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 & 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×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/<slug>/
|
||
├── 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×136</b> through Graphviz — a 235:1 strip — and
|
||
<b>1740×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 -> 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 70–106 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×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→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 → Graphviz → 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 & 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→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=<dir></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 & 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>
|