docgen
++ 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. +
+ + +What it is
+ +
+ Eight demos under semester/ draw their architecture with
+ Graphviz. Every one of them hand-writes a .dot file with the
+ palette inlined and commits the .svg 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.
+
+ 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 + what a graph is from how it looks so one extraction feeds + a diagram, an index, a notebook and a browsable site without being redone. +
+ +ast, 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
+ we made the call.
+The idea
+ ++ N sources and M outputs need N×M converters if you join them directly, + or N+M if you put a hub in the middle. The hub is an + intermediate representation — the compiler term, and the same bargain: + both sides depend on the IR and neither on the other. +
++ It is lossy on purpose. It throws away every token of syntax and keeps + "a class named User inherits from Base". That is the part that is + worth versioning, worth diffing, and worth drawing. +
++ The practical consequence is the thing to judge it on: 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. 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. +
+ + +Five minutes
+ +Three commands, and they compose. That is the whole interface.
+ +# 1. read something
+python3 -m docgen.extractors.python --root ../station/tools/histgen -o ir.json
+
+# 2. narrow it to a useful view
+python3 -m docgen.ops ir.json --overview -o view.json
+
+# 3. draw whatever its structure asks for
+python3 -m docgen.emitters auto view.json -o out/
+
+Or through the Makefile, which is a thin wrapper over exactly those:
+ +make ir SRC=/path/to/repo OUT=out # extract
+make explore OUT=out # the two-pane navigator
+make site OUT=out # a docs site with a sidebar
+make self # run the whole thing over soleprint
+
+
+ Everything is offline and self-contained. No server, no CDN, no build step —
+ the outputs open over file://.
+
The three concerns
+ +
+ DOT collapses three separate questions into one file format, which is why a
+ hand-written .dot 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.
+
| concern | question | owner |
|---|---|---|
| structure | what the graph is | ir/schema.json |
| meaning | what things mean visually | style/*.json, keyed on kind |
| placement | where things go | the emitter, and only there |
+ An extractor has never heard of SVG, colours or layout. An emitter has never
+ heard of Python, ast 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.
+
extractors/ reaches only ir;
+ emitters/ reaches ir and style;
+ ir/ reaches nothing outside itself; lab/ has no
+ edges at all. Click to open the viewer — then click again for actual size.
+ The IR
+ ++ Plain JSON. Three keys, and it has survived four domains without gaining a + fourth. +
+ +{
+ "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": {} } ]
+}
+
+| field | meaning |
|---|---|
id |
+ Fully qualified and stable across runs. Stability is what makes + two extractions from two commits diffable; without it a diff reports + noise and nobody trusts it. |
kind |
+ The hinge of the whole system, and the only field style and
+ layout may read. A small closed vocabulary per domain —
+ module/class/function,
+ table/column, endpoint,
+ task. |
parent |
+ Containment, and nothing else. A module contains a class. Relationships + are edges. |
attrs |
+ An open bag for whatever one domain cares about.
+ file/line/lines are what let a
+ box link to the line it came from, and what the minimap sizes by. |
shape: "cylinder" is
+ not a field — it is kind: "datastore" 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.
+Stdlib dataclasses, not Pydantic
+
+ 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 schema.json so the schema and
+ the dataclasses cannot drift apart.
+
python3 -m docgen.ir ir.json
+
+ 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
+ attrs.
+
Shape decides the drawing
+ ++ A diagram that fights its layout engine is usually the wrong kind of + diagram. The clearest evidence: the same 24-table database rendered + 32034×136 through Graphviz — a 235:1 strip — and + 1740×1860 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. +
+
+ So ops.classify() reads the structure and names the emitter,
+ with the reason attached — advice without a reason gets overridden the first
+ time it is inconvenient.
+
| kind | drawn by | when |
|---|---|---|
erd | cards in columns | entities with references |
pipeline | ranks, left to right | a chain with fan-out — an Airflow DAG, a build |
layered / tree | ranks, top down | ranks genuinely suit it |
sheet | the index | one level is wider than ~20 — a strip in any engine |
flat | the index | most nodes have no relationships: that is a list |
$ 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
+
+Why twenty
++ 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 graph, not of the renderer — a layered engine puts one + dependency level in one row, so the widest level is the width. +
+
+ Every Graphviz lever was tried before concluding this. ratio=compress
+ squashed a graph to an unreadable 1008×75; rankdir=LR 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 explore does.
+
Extractors
+ ++ Deterministic parsing only. No model in the structural path. + 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. +
+ +| reader | reads | gives |
|---|---|---|
python | a tree of .py |
+ modules, classes, functions; imports and inherits edges |
code tree-sitter | C#, TypeScript, TSX | +namespaces, classes, interfaces, methods — structure only |
db | a graphgen-compatible schema.json |
+ tables, columns, foreign keys |
openapi | an OpenAPI / Swagger document | +endpoints and the shapes they carry |
usage | a HAR recording | +what was actually called, in what order |
Two passes, because ast resolves nothing
+
+ Given class User(Base), Python's ast hands over the
+ literal string "Base". It has no idea that came from
+ from .db import Base 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 app.db.Base — a real
+ node — rather than at a box called Base that means nothing.
+
kind: "external" and
+ keeps its edge. 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.
+C# and TypeScript
++ 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. +
+
+ This reader produces no edges. Resolving a C#
+ using 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.
+
Usage, not just the spec
+ ++ An OpenAPI document says what endpoints are. 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 HAR: the recording format that + browser devtools, mitmproxy, Charles and Insomnia all export. +
+ +| what traffic knows | what a spec cannot |
|---|---|
| the order of calls | a spec is a set; usage is a sequence |
| which parameters are always sent | a spec lists twenty optional ones |
| which statuses really happen | the 422 everybody hits is in no document |
| endpoints not in the document | GraphQL operations, found by body shape and named |
| which id formats a route takes | numeric and uuid on one route |
+ Two limits, stated rather than glossed: path templating is a guess
+ (attrs.observed_paths 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.
+
Views
+ +
+ The first real diagram out of this pipeline was a 3000px-wide strip: four
+ modules of actual content and sixty sys/json/typing
+ boxes as their peers. The emitter was correct and the picture was useless.
+ That is a missing view, 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.
+
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 # what will this look like?
+
+
+ All of them are IR→IR, all composable, and each produces a document that
+ still validates. --overview 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.
+
weight saying how many they stand for.
+Depth is the tempting knob and the wrong one
+
+ A directory without an __init__.py is not a package, so its
+ modules have no parent and sit at depth 0. soleprint has 173 such roots,
+ and a depth-2 cut still held 566 functions and 142 classes. Selecting by
+ kind does not care how the directories happen to be arranged.
+
Emitters
+ +| emitter | output | audience |
|---|---|---|
index | markdown, sidebar JSON | +anyone — no graph literacy required |
dot | DOT → Graphviz → SVG | dependency structure |
erd | SVG, written directly | a schema, as cards |
minimap | SVG, written directly | what is where, at a glance |
notebook | .ipynb | a runnable walkthrough |
site | a static docs site | reading |
explore | a two-pane navigator | finding your way |
auto | whichever of the above fits | not having to choose |
+ The non-visual ones matter most for reach. 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. +
+ +ERD — and where the layout came from
+
+ Not invented here. station/tools/graphgen/templates/index.html,
+ the Supabase-style schema explorer already in this repo, had solved it:
+
const cols = Math.max(2, Math.ceil(Math.sqrt(sorted.length * 1.2)));
++ Columns from the square root of the table count. The aspect + ratio is chosen 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 card 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. +
+ +Minimap
++ Sublime's minimap shrinks the characters. This draws the + structure 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. +
++ 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. +
+ +Explore
+ ++ The minimap on its own shows shape and no meaning: a block says "a 30-line + class", not which class or what it touches. So it is not the artifact. + It is the selector. +
+ +make explore OUT=out # then open out/explore/explore.html
+
+Left — navigate
+The whole thing at once. Scan by colour, click a block.
+Right — explore
+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.
++ The same split applies to a database: every table at once with no column + detail, 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. +
+ +The selection basket
+
+ Shift-click accumulates blocks. The basket is a copyable list of paths with a
+ line count — enough to hand to distill, and enough to see that the
+ selection got too big before 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.
+
Notebooks
+ ++ 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. +
+
+ Here a notebook is a build artifact. The source is the OpenAPI
+ document — the same file the server is built from — and the notebook is
+ regenerated from it. Nobody edits the .ipynb, the same way nobody
+ edits a .o. "Is this document current" stops being a question
+ about somebody's diligence and becomes a question about whether the build ran.
+
+ This is the disagreement with jupytext. Jupytext fixes the diffing — + it makes a notebook editable as text — and leaves the actual problem: you still + hand-author it, so it still rots. ++ +
Generated base, hand-written overlay
++ 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: +
+ +IR ──► spec ──(+ overlay)──► merged spec ──► .ipynb
+ generated hand-written merged emitted
+
+
+ The spec is an ordered list of steps with no Jupyter in it — a Swagger
+ for notebooks, readable and diffable. The overlay is the only file
+ anyone edits, and it is re-applied on every build. It can
+ annotate, replace, insert,
+ drop and order.
+
+ replace 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
+ status=available, 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.
+
python3 -m docgen.emitters notebook ir.json --scaffold overlay.json
+python3 -m docgen.emitters notebook ir.json --overlay overlay.json -o walkthrough.ipynb
+
+
+Style & colour
+ +
+ A style rule names a slot, never a colour.
+ "border": "atlas" is the rule; a theme binds atlas to
+ #43A047 in print and #15803d on the docs site.
+
+ That indirection is the whole point. common/theme/tokens.css,
+ docs/graphs/themes/*.gvpr and style/lucid.json use
+ the same slot names, so a diagram and the page around it match by construction
+ — which is the rule docs/graphs/README.md already states. The dark
+ theme's artery, atlas and station slots
+ are exactly the --system-accent values the three system pages set,
+ and the test suite fails if they drift apart.
+
+ Dark is the default, because a generated diagram lands in a dark docs page far
+ more often than in a document. --theme lucid gives the print
+ palette — and gives it to the page as well as the diagram, since both
+ are baked from the same slots.
+
kind falls back to default 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.
+Where DOT stops
+
+ 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;
+ stroke-dasharray is not parameterised, so 4,4 and
+ 5,5 collapse; rounded 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.
+
+ One limit was worth solving: DOT cannot use a cluster as an edge
+ endpoint, so every module-to-module import silently vanished. The native answer
+ is compound=true with lhead/ltail — draw
+ between a representative leaf and clip the line at the cluster border.
+
Commands
+ +Make
+| target | does |
|---|---|
make check | the whole test suite, offline, nothing installed |
make doctor | what this machine has and what it is missing |
make ir SRC=… | extract Python into OUT/ir.json |
make code SRC=… | extract C#/TypeScript tree-sitter |
make db SCHEMA=… | extract a database schema |
make view | the default view for that source type |
make graph | draw whatever the structure asks for |
make index | markdown index and sidebar JSON |
make minimap | what is where, read from the colours |
make explore | the two-pane navigator |
make site | a self-contained docs site |
make self | the whole pipeline over soleprint itself |
+ Variables: SRC, OUT, SCHEMA,
+ STYLE, THEME, SCALE, DEPTH,
+ PY. The Makefile derives its own package name from where it sits,
+ so the folder can be copied anywhere and renamed and still work.
+
Modules
+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 # validate
+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/
+
+
+Dependencies
+ +
+ The core is standard library only. Everything else is optional
+ and reported by make doctor; when something is missing you lose
+ exactly one capability and get told what to install.
+
| needs | for | without it |
|---|---|---|
graphviz (binary) | rendering DOT to SVG | +ERD, minimap, index and notebooks still work |
tree_sitter + grammars | C#, TypeScript, TSX | +Python only |
networkx | lab/ experiments |
+ nothing — nothing depends on it yet |
node | testing the browser pages | +those checks skip |
psql | the lab/ schema probe |
+ use modelgen's from-db instead |
lab/ is where a dependency gets tried before anything depends
+ on it. Nothing in ir/, extractors/,
+ ops/ or emitters/ may import from it. When an
+ experiment earns its place it graduates into ops/ behind an
+ IR→IR signature, and then the dependency is declared.
+Testing
+ +make check # 191 checks, offline, no network
+
++ Four of those are the design rather than regressions, and they are the + ones to keep if anything is ever cut: +
+-
+
- No visual field reaches the IR — extractors cannot decide appearance. +
- No emitter reads a source file — the layering, checked from the other side by parsing imports. +
- Style names slots, not colours — one colour language rather than three. +
- Ids are stable across runs — without it, diffing is noise. +
+ The browser pages are JavaScript, so they are tested as JavaScript: a stub DOM
+ under node drives the viewer's zoom and 1:1 toggle, and the
+ explorer's select-and-walk. Both skip cleanly where node is absent.
+
+ 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. make self runs the whole pipeline over soleprint; if the
+ index does not read like the system, something is wrong.
+
Limits & non-goals
+ +Things deliberately not done, with the reason, so they are not re-litigated:
+ +-
+
- No layout engine. No positioning, no
neato -n2, no ELK. + Aspect ratio was solved by choosing the right emitter and by not drawing + everything at once.
+ - No
callsedges. Resolvingself.foo()needs + type inference, and a call graph that is quietly 60% right is worse than + none because it reads as authoritative.
+ - No edges from the C#/TypeScript reader. Structure only — half a + dependency graph would look whole. +
- No model in the structural path. 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. +
- No configuration knobs until two real consumers disagree. +
Known gaps, stated plainly:
+-
+
- The C# reader is verified against a written fixture, not a real + repository. That is the next check that matters. +
- 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. +
- 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. +