diff --git a/soleprint/atlas2/docgen/Makefile b/soleprint/atlas2/docgen/Makefile
index acb3c21..038b330 100644
--- a/soleprint/atlas2/docgen/Makefile
+++ b/soleprint/atlas2/docgen/Makefile
@@ -3,13 +3,18 @@
# Derived from where this file sits, so the folder can be copied anywhere and
# renamed and still work. The logic lives in the Python, never here.
#
-# make check prove it, on a tree it builds itself
-# make ir SRC=../station extract -> out/ir.json
+# make book SRC=../station the whole operation, measured at both ends
+# make check prove docgen, on a tree it builds itself
+# make check BOOK=out/book/x prove one book — its own level
+# make ir SRC=../station extract -> out/ir.json (one step, on its own)
# make graph out/ir.json -> out/graph.svg
# make index out/ir.json -> out/index.md
-# make self run the whole pipeline over soleprint itself
+# make self docgen's book of itself, then check it
# make doctor what this machine has
#
+# Every target below is one step of a book and still works alone — that is the
+# property the book spine exists to preserve, not to replace.
+#
# The pipeline is three commands and they compose, which is the point:
#
# python3 -m docgen.extractors.python --root SRC -o ir.json
@@ -25,14 +30,24 @@ RUN := PYTHONPATH=$(PARENT) $(PY) -m
OUT ?= $(HERE)/out
SRC ?=
SCHEMA ?=
+OPENAPI ?=
+HAR ?=
STYLE ?= lucid
THEME ?=
DEPTH ?= 2
SCALE ?= 0.55
+BOOK ?=
+SLUG ?=
+# NOT `LANG`: that is the shell's locale variable, so `?=` inherits
+# en_US.UTF-8 from the environment and --lang rejects it.
+READER ?= python
+OVERLAY ?=
THEME_ARG := $(if $(THEME),--theme $(THEME))
+SLUG_ARG := $(if $(SLUG),--slug $(SLUG))
+OVER_ARG := $(if $(OVERLAY),--overlay $(OVERLAY))
-.PHONY: help check ir db code graph index site minimap explore docs view self doctor clean
+.PHONY: help book check ir db code graph index site minimap explore docs view self doctor clean
help: ## List every target
@echo "docgen — static analysis of a tree, and the artifacts that fall out of it"
@@ -42,10 +57,34 @@ help: ## List every target
@echo
@echo " SRC=/path/to/tree what to read OUT=/path where output goes"
@echo " SCHEMA=schema.json a database instead STYLE=lucid THEME=dark|lucid"
+ @echo " OPENAPI=spec.yaml an API document HAR=session.har a recording"
+ @echo " BOOK=/path where a book goes, and which book to check"
+ @echo " READER=python|code ast, or tree-sitter SLUG=name what to call the book"
+ @echo " OVERLAY=overlay.json hand-written notebook additions, re-applied every build"
@echo " DEPTH=2 how deep to draw"
-
-check: ## Prove the pipeline, offline, needing nothing installed
- @$(PY) $(HERE)/selftest.py
+ @echo
+ @echo " Three levels of test, by what they assert about:"
+ @echo " make doctor the machine. Never fails."
+ @echo " make check docgen. Exits 1."
+ @echo " make check BOOK=
that book. Exits 1."
+
+check: ## Prove docgen (or one book, with BOOK=)
+ @if [ -n "$(BOOK)" ]; then \
+ $(RUN) $(PKG).book.checks "$(BOOK)"; \
+ else \
+ $(PY) $(HERE)/selftest.py; \
+ fi
+
+book: ## SRC (or SCHEMA/OPENAPI/HAR) -> one operation, measured at both ends
+ @test -n "$(SRC)$(SCHEMA)$(OPENAPI)$(HAR)" \
+ || { echo "Error: set SRC=/path/to/tree (or SCHEMA=, OPENAPI=, HAR=)" >&2; exit 1; }
+ @$(RUN) $(PKG).book \
+ $(if $(SRC),--root "$(SRC)" --lang $(READER)) \
+ $(if $(SCHEMA),--schema "$(SCHEMA)") \
+ $(if $(OPENAPI),--openapi "$(OPENAPI)") \
+ $(if $(HAR),--har "$(HAR)") \
+ -o "$(if $(BOOK),$(BOOK),$(OUT)/book)" \
+ --style $(STYLE) $(THEME_ARG) $(SLUG_ARG) $(OVER_ARG)
ir: ## Extract SRC into OUT/ir.json
@test -n "$(SRC)" || { echo "Error: set SRC=/path/to/tree" >&2; exit 1; }
@@ -104,15 +143,14 @@ index: ## OUT/ir.json -> OUT/index.md and OUT/sidebar.json
@$(RUN) $(PKG).emitters index $(OUT)/ir.json -o $(OUT)/index.md
@$(RUN) $(PKG).emitters index $(OUT)/ir.json -o $(OUT)/sidebar.json
-self: ## Run the whole pipeline over soleprint itself — the honest end-to-end check
- @$(MAKE) --no-print-directory ir SRC=$(PARENT)/.. OUT=$(OUT)
- @$(MAKE) --no-print-directory index OUT=$(OUT)
- @$(MAKE) --no-print-directory graph OUT=$(OUT)
- @$(MAKE) --no-print-directory site OUT=$(OUT)
- @$(MAKE) --no-print-directory minimap OUT=$(OUT)
- @$(MAKE) --no-print-directory explore OUT=$(OUT)
+self: ## docgen's book of the widest tree it can see, then check it
+ @$(eval SELF_SRC := $(shell PYTHONPATH=$(PARENT) $(PY) -c "from $(PKG) import reference; \
+r = reference.root(); print(r if r else '$(HERE)')"))
+ @echo " self-hosting on $(SELF_SRC)"
+ @$(MAKE) --no-print-directory book SRC=$(SELF_SRC) SLUG=self \
+ BOOK=$(OUT)/book/self OUT=$(OUT)
@echo
- @echo " Read $(OUT)/index.md, or open $(OUT)/site/index.html"
+ @$(MAKE) --no-print-directory check BOOK=$(OUT)/book/self
doctor: ## Report whether this machine can run it
@printf 'python : '; $(PY) --version 2>&1 || echo MISSING
@@ -120,6 +158,9 @@ doctor: ## Report whether this machine can run it
@printf 'tree-sit : '; $(PY) -c 'import tree_sitter, tree_sitter_c_sharp, tree_sitter_typescript; print("ok — C# and TypeScript available")' 2>/dev/null || echo 'absent — Python only. pip install tree_sitter tree_sitter_c_sharp tree_sitter_typescript'
@printf 'networkx : '; $(PY) -c 'import networkx; print(networkx.__version__ + " — for lab/ experiments")' 2>/dev/null || echo 'absent — only used in lab/'
@printf 'package : %s (from %s)\n' '$(PKG)' '$(PARENT)'
+ @printf 'yaml : '; $(PY) -c 'import yaml; print("ok — needed only to read OpenAPI")' 2>/dev/null || echo 'absent — only used by the OpenAPI reader'
+ @printf 'reference: '; PYTHONPATH=$(PARENT) $(PY) -c "from $(PKG) import reference; print(reference.describe())"
+
@printf 'styles : '; $(RUN) $(PKG).style 2>/dev/null \
|| $(RUN) $(PKG) 2>/dev/null \
|| PYTHONPATH=$(PARENT) $(PY) -c "from $(PKG).style import Style; print(', '.join(Style.available()))"
diff --git a/soleprint/atlas2/docgen/book/__init__.py b/soleprint/atlas2/docgen/book/__init__.py
new file mode 100644
index 0000000..f5fea7e
--- /dev/null
+++ b/soleprint/atlas2/docgen/book/__init__.py
@@ -0,0 +1,315 @@
+"""
+A **book** — one docgen operation, measured at both ends.
+
+ larder ──► step ──► step ──► step ──► book
+ what each one usable what
+ came in by itself came out
+
+The first step says what went into the operation. The last step says what came
+out, and renders it for the web. Everything between is an ordinary artifact that
+stands on its own — `ir.json` is still an `ir.json`, and `make ir` still works
+without knowing this file exists.
+
+## Why both ends, and why they are steps rather than a wrapper
+
+Because the two numbers are only worth having **together**. "102 nodes" is not a
+fact about anything; "49 files in, 102 nodes out, nothing lost" is. A book that
+read 45 of 47 files and drew a clean diagram is lying by omission, and before
+this there was no place for the other 2 to be mentioned.
+
+They are steps, not a wrapper, because a wrapper is something you can forget to
+apply. A step is in the sequence, and the sequence is the thing the notebook
+emits — so the measure is in the document whether or not anyone remembered.
+
+## The two things it is not
+
+**Not a gate.** Running one step alone is still a book, just a short one. An
+operation that cannot measure something says what it could not measure and
+carries on. Gating would break the property that makes the intermediate
+artifacts useful, and that property is the whole reason the sequence is worth
+having.
+
+**Not a new pipeline.** Everything here composes functions that already existed
+— `ops.overview`, `emitters.dot`, `emitters.site`, `notebook.spec`. The spine
+adds a ledger and two measurements. If it ever starts doing the work itself,
+something has gone wrong.
+
+## The web end is deliberately the loose one
+
+It is last, so nothing depends on it, so it can be replaced wholesale without
+touching a single thing upstream. That is what lets it rule what gets generated
+without being a stable contract: the book measure is the promise, and the page
+that displays it is free to change drastically and often.
+"""
+
+import json
+from dataclasses import dataclass, field
+from pathlib import Path
+
+from .larder import Larder
+
+# The two spine steps. Named here because the notebook, the site and the checks
+# all have to agree on what they are called, and three string literals in three
+# files is how they stop agreeing.
+FIRST = "larder"
+LAST = "book"
+
+
+@dataclass
+class Step:
+ """One thing the operation did, and what it left behind."""
+
+ id: str
+ label: str
+ artifact: str | None = None # relative to the book directory
+ bytes: int = 0
+ note: str = ""
+ skipped: str = "" # why, when it did not run
+
+ def to_dict(self) -> dict:
+ out = {"id": self.id, "label": self.label}
+ if self.artifact:
+ out["artifact"] = self.artifact
+ out["bytes"] = self.bytes
+ if self.note:
+ out["note"] = self.note
+ if self.skipped:
+ out["skipped"] = self.skipped
+ return out
+
+
+# How a larder's unit shows up on the output side. Per-kind because the relation
+# genuinely differs, and because getting it wrong produces a check that passes
+# for the wrong reason — which is what the first version of this did.
+#
+# relation "exact" one unit in, one node out. Fewer means input was dropped.
+# "collapse" many units in, fewer nodes out, by design.
+# noun what to call the output-side thing, in the claim
+# represents whether a unit that FAILED still appears as a node. Where it does,
+# that is checkable and is the whole-input form of the rule that an
+# unresolved name becomes an `external` node rather than vanishing.
+RELATION = {
+ "python": ("exact", "module", True),
+ "code": ("exact", "module", True),
+ "db": ("exact", "table", False),
+ "openapi": ("exact", "endpoint", False),
+ # A HAR entry with no URL has nothing to represent, so there is no node to
+ # look for. Its absence is the correct outcome and is recorded in `failed`.
+ "usage": ("collapse", "call", False),
+}
+
+
+def _unit_counts(ir: dict, larder: Larder) -> tuple[int, int]:
+ """(nodes from units that were read, nodes from units that failed).
+
+ Split because a failed file still gets a module node — carrying
+ `attrs.error`, so the gap is visible in the graph rather than only in a log.
+ Counting them together made "2 files read produced 4 modules" pass a check
+ that was supposed to prove nothing had been dropped.
+ """
+ nodes = ir.get("nodes") or []
+
+ if larder.kind in ("python", "code"):
+ mods = [n for n in nodes
+ if n["kind"] == "module" and (n.get("attrs") or {}).get("file")]
+ broken = {n["attrs"]["file"] for n in mods if (n.get("attrs") or {}).get("error")}
+ whole = {n["attrs"]["file"] for n in mods} - broken
+ return len(whole), len(broken)
+
+ if larder.kind == "db":
+ return sum(1 for n in nodes if n["kind"] == "table"), 0
+
+ if larder.kind == "openapi":
+ return len({
+ (n.get("attrs") or {}).get("path") for n in nodes
+ if n["kind"] == "endpoint" and (n.get("attrs") or {}).get("path")
+ }), 0
+
+ if larder.kind == "usage":
+ return sum(1 for n in nodes if n["kind"] in ("endpoint", "operation")), 0
+
+ return 0, 0
+
+
+class Book:
+ """A named operation, its ledger, and the two measures that bracket it."""
+
+ def __init__(self, slug: str, larder: Larder, out):
+ self.slug = slug
+ self.larder = larder
+ self.out = Path(out)
+ self.steps: list[Step] = []
+ self.ir: dict | None = None
+ self.notebook: str | None = None
+
+ # The first step, recorded before any work happens. Doing it here rather
+ # than at the end is the difference between a measure and a summary: it
+ # says what the operation *set out* to read, so a crash halfway leaves a
+ # book that still says what went in.
+ self.steps.append(Step(
+ id=FIRST,
+ label="what came in",
+ note=larder.line(),
+ ))
+
+ # -- the ledger -------------------------------------------------------
+
+ def step(self, id: str, label: str, path=None, note: str = "",
+ skipped: str = "") -> Step:
+ """Record a step. `path` is written already; this measures it."""
+ artifact, size = None, 0
+ if path is not None:
+ path = Path(path)
+ if path.exists():
+ artifact = str(path.relative_to(self.out)) if self.out in path.parents \
+ or path.parent == self.out else str(path)
+ size = path.stat().st_size if path.is_file() else _tree_bytes(path)
+ s = Step(id=id, label=label, artifact=artifact, bytes=size,
+ note=note, skipped=skipped)
+ self.steps.append(s)
+ return s
+
+ # -- the last step ----------------------------------------------------
+
+ def measure(self) -> dict:
+ """What came out. Counted off the final IR and the ledger."""
+ ir = self.ir or {"nodes": [], "edges": []}
+ by_kind: dict[str, int] = {}
+ for n in ir.get("nodes") or []:
+ by_kind[n["kind"]] = by_kind.get(n["kind"], 0) + 1
+ edge_kinds: dict[str, int] = {}
+ for e in ir.get("edges") or []:
+ edge_kinds[e["kind"]] = edge_kinds.get(e["kind"], 0) + 1
+
+ artifacts = [s for s in self.steps if s.artifact]
+ return {
+ "nodes": sum(by_kind.values()),
+ "by_kind": {k: by_kind[k] for k in sorted(by_kind)},
+ "edges": sum(edge_kinds.values()),
+ "edges_by_kind": {k: edge_kinds[k] for k in sorted(edge_kinds)},
+ "external": by_kind.get("external", 0),
+ "steps": len(self.steps),
+ "artifacts": [{"path": s.artifact, "bytes": s.bytes} for s in artifacts],
+ "bytes": sum(s.bytes for s in artifacts),
+ }
+
+ def compare(self) -> list[dict]:
+ """Reconcile the two ends. This is what having both is *for*.
+
+ Returns observations, each with a verdict, rather than raising: the book
+ is already built by the time anyone can compare, and refusing to write it
+ would destroy the evidence. `book/checks.py` turns these into pass/fail
+ at the book test level, and the CLI exits 1 when one of them is not ok.
+ """
+ out = []
+ ir = self.ir or {}
+ produced, represented = _unit_counts(ir, self.larder)
+ relation, noun, represents = RELATION.get(
+ self.larder.kind, ("collapse", "node", False))
+ read, unit = self.larder.read, self.larder.unit
+ n_failed = len(self.larder.failed)
+
+ if relation == "exact":
+ ok = produced >= read
+ out.append({
+ "id": "units-accounted-for",
+ "ok": ok,
+ "claim": f"{read} {unit}(s) read produced {produced} {noun}(s)",
+ "why": (
+ "one unit in, one node out" if ok else
+ f"{read - produced} {unit}(s) were read and produced nothing — "
+ "the input was dropped between the extractor and the document, "
+ "which is the failure this measure exists to catch"
+ ),
+ })
+ else:
+ out.append({
+ "id": "collapse-is-intended",
+ "ok": produced >= 1 or read == 0,
+ "claim": f"{read} {unit}(s) collapsed to {produced} {noun}(s)",
+ "why": "many recorded requests describe few endpoints — that is the point",
+ })
+
+ if n_failed:
+ out.append({
+ "id": "failures-surfaced",
+ "ok": True,
+ "claim": f"{n_failed} {unit}(s) could not be read",
+ "why": "named in the larder measure and on the landing page, not only in a log",
+ })
+
+ # The whole-input form of "an unresolved name becomes an `external` node".
+ # A file that failed to parse must still be in the graph, or the diagram
+ # shows a tree that is smaller than the tree on disk and says nothing.
+ if represents and n_failed:
+ out.append({
+ "id": "failures-still-in-the-graph",
+ "ok": represented >= n_failed,
+ "claim": f"{n_failed} unreadable {unit}(s) appear as {represented} "
+ f"marked {noun}(s)",
+ "why": (
+ "a gap that is drawn can be seen" if represented >= n_failed else
+ f"{n_failed - represented} unreadable {unit}(s) are missing from the "
+ "graph entirely — the picture is smaller than the source and does "
+ "not say so"
+ ),
+ })
+ return out
+
+ # -- writing ----------------------------------------------------------
+
+ def close(self, *, site=None) -> Step:
+ """Append the last step. Call once, after the web output is written.
+
+ Must be called BEFORE the notebook is built, because the notebook quotes
+ the book measure and the measure is not complete until this step exists.
+
+ The site is this step's *artifact*. The notebook is not: it is the whole
+ sequence's rendering rather than an item in it, and it is set on
+ `self.notebook` afterwards, by name only — a notebook cannot report its
+ own byte count without changing it.
+ """
+ m = self.measure()
+ summary = " · ".join(f"{v} {k}" for k, v in
+ sorted(m["by_kind"].items(), key=lambda kv: -kv[1]))
+ artifact, size = None, 0
+ if site is not None:
+ site = Path(site)
+ if site.exists():
+ artifact, size = str(site.relative_to(self.out)), _tree_bytes(site)
+ self.steps.append(Step(
+ id=LAST,
+ label="what came out",
+ artifact=artifact,
+ bytes=size,
+ note=f"{summary} · {m['edges']} edges" if summary else "nothing was produced",
+ ))
+ return self.steps[-1]
+
+ def to_dict(self) -> dict:
+ """The book, as the ledger someone else can read.
+
+ `generated_at` is absent for the same reason the IR's is: an unchanged
+ larder must serialise to the same bytes, or nothing downstream can tell
+ a real change from a rebuild.
+ """
+ out = {
+ "slug": self.slug,
+ "larder": self.larder.to_dict(),
+ "steps": [s.to_dict() for s in self.steps],
+ "book": self.measure(),
+ "reconciled": self.compare(),
+ }
+ if self.notebook:
+ out["notebook"] = self.notebook
+ return out
+
+ def write(self) -> Path:
+ self.out.mkdir(parents=True, exist_ok=True)
+ path = self.out / "book.json"
+ path.write_text(json.dumps(self.to_dict(), indent=2, sort_keys=False) + "\n")
+ return path
+
+
+def _tree_bytes(root: Path) -> int:
+ return sum(p.stat().st_size for p in root.rglob("*") if p.is_file())
diff --git a/soleprint/atlas2/docgen/book/__main__.py b/soleprint/atlas2/docgen/book/__main__.py
new file mode 100644
index 0000000..159d94f
--- /dev/null
+++ b/soleprint/atlas2/docgen/book/__main__.py
@@ -0,0 +1,80 @@
+""" python3 -m docgen.book --root ../station -o out/book/station
+
+One book: larder measure, the steps, the web output, book measure. The step
+artifacts land in `steps/` and are ordinary files — nothing here needs this
+command to have been the thing that produced them.
+"""
+
+import argparse
+import sys
+from pathlib import Path
+
+KINDS = ("python", "code", "db", "openapi", "usage")
+
+
+def main(argv=None) -> int:
+ p = argparse.ArgumentParser(
+ prog="python3 -m docgen.book",
+ description="Run one docgen operation, measured at both ends.",
+ )
+ src = p.add_mutually_exclusive_group(required=True)
+ src.add_argument("--root", type=Path, help="A source tree (Python, or --lang code).")
+ src.add_argument("--schema", type=Path, help="A graphgen-compatible schema.json.")
+ src.add_argument("--openapi", type=Path, help="An OpenAPI document.")
+ src.add_argument("--har", type=Path, help="A HAR recording.")
+ p.add_argument("--lang", choices=("python", "code"), default="python",
+ help="With --root: the stdlib ast reader, or tree-sitter. Default python.")
+ p.add_argument("--output", "-o", type=Path, required=True,
+ help="The book directory. Created if absent.")
+ p.add_argument("--slug", help="What to call it. Defaults to the source's name.")
+ p.add_argument("--style", default="lucid")
+ p.add_argument("--theme", default=None, help="dark (default) or lucid.")
+ p.add_argument("--overlay", type=Path,
+ help="A hand-written overlay, re-applied on every build.")
+ p.add_argument("--exclude", action="append", default=[],
+ help="Directory name to skip. Repeatable.")
+ p.add_argument("--quiet", "-q", action="store_true")
+ args = p.parse_args(argv)
+
+ if args.root is not None:
+ kind, source = args.lang, args.root
+ elif args.schema is not None:
+ kind, source = "db", args.schema
+ elif args.openapi is not None:
+ kind, source = "openapi", args.openapi
+ else:
+ kind, source = "usage", args.har
+
+ if not Path(source).exists():
+ print(f"Error: {source} does not exist", file=sys.stderr)
+ return 1
+
+ overlay = None
+ if args.overlay:
+ if not args.overlay.exists():
+ # Absent is fine and is the documented default; named-but-missing is
+ # a typo, and quietly building without it would hide the typo.
+ print(f"Error: overlay {args.overlay} does not exist", file=sys.stderr)
+ return 1
+ from ..notebook import spec as spec_mod
+ overlay = spec_mod.load(args.overlay)
+
+ from .build import run
+
+ try:
+ book = run(kind, source, args.output, slug=args.slug, style=args.style,
+ theme=args.theme, exclude=tuple(args.exclude), overlay=overlay,
+ quiet=args.quiet)
+ except Exception as e: # noqa: BLE001 - the CLI reports, it does not traceback
+ print(f"Error: {type(e).__name__}: {e}", file=sys.stderr)
+ return 1
+
+ # Exit 1 when the two ends do not reconcile. The book is still written —
+ # the evidence is the point — but a build that lost input should fail a
+ # pipeline rather than pass quietly.
+ lost = [r for r in book.compare() if not r.get("ok")]
+ return 1 if lost else 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/soleprint/atlas2/docgen/book/build.py b/soleprint/atlas2/docgen/book/build.py
new file mode 100644
index 0000000..79fc92b
--- /dev/null
+++ b/soleprint/atlas2/docgen/book/build.py
@@ -0,0 +1,350 @@
+"""
+Run a book: larder, the steps, the web output, the book measure.
+
+ python3 -m docgen.book --root ../station -o out/book/station
+
+Composes functions that already exist. Nothing here parses, lays out or styles
+anything — `ops.overview`, `emitters.dot`, `emitters.site` and `notebook.spec`
+do all of it, and this decides the order and writes the ledger. If this file
+ever starts doing the work, the seam has moved to the wrong place.
+
+## The notebook is the sequence, not an item in it
+
+So it is not a step. Its **first cell is the larder measure and its last cell is
+the book measure**, which is what makes the two ends part of the document rather
+than part of the tooling. Between them, one pair of cells per step: what the
+step did, and a code cell that loads that step's artifact on its own.
+
+That last part is the "usable by themselves" property made executable. Each cell
+reads one artifact and prints one fact, so a reader can start anywhere in the
+sequence, and `selftest.py` runs them.
+"""
+
+import json
+from pathlib import Path
+
+from . import Book
+from .larder import Larder
+
+# Loader for the notebook's step cells. Guarded on purpose: a notebook is
+# opened from wherever somebody happens to open it, and a traceback on cell 2
+# is a worse answer than a sentence saying which directory to run it from.
+PRELUDE = '''from pathlib import Path
+import json
+
+BOOK = Path.cwd() # the book directory — change if you opened this elsewhere
+
+
+def load(rel):
+ """One step's artifact, on its own. Returns None when it is not here.
+
+ A step's artifact can be a directory — the explorer is one — so this returns
+ a file list for those rather than trying to read a directory as text.
+ """
+ p = BOOK / rel
+ if not p.exists():
+ print(f"{rel} is not here — run: make book OUT={BOOK}")
+ return None
+ if p.is_dir():
+ return sorted(f.relative_to(p).as_posix() for f in p.rglob("*") if f.is_file())
+ return json.loads(p.read_text()) if p.suffix == ".json" else p.read_text()
+'''
+
+
+# Every extractor a book can run, as data rather than as an if/elif chain.
+#
+# This is the shape that gives the extension contract teeth. `selftest.py` loops
+# over this dict and asserts each entry reports a larder measure — so adding an
+# extractor makes the test start asking about it without anyone remembering to
+# go and add a case. rig's `CONFIG_OVERRIDABLE` loop is the precedent, and it
+# found two real bugs that way.
+#
+# kind -> (module, function, what the source is)
+EXTRACTORS = {
+ "python": (".extractors.python", "extract", "tree"),
+ "code": (".extractors.code", "extract", "tree"),
+ "db": (".extractors.db", "extract", "file"),
+ "openapi": (".extractors.openapi", "extract", "file"),
+ "usage": (".extractors.usage", "extract", "file"),
+}
+
+# Extractors that walk a directory take an exclude list; the ones that read a
+# single document have nothing to exclude.
+TAKES_EXCLUDE = {"python", "code"}
+
+
+def extract(kind: str, source, *, exclude=(), identity=None):
+ """(IR dict, Larder) for one source type, dispatched through EXTRACTORS."""
+ from importlib import import_module
+
+ if kind not in EXTRACTORS:
+ raise ValueError(
+ f"no extractor named {kind!r} — have {', '.join(sorted(EXTRACTORS))}"
+ )
+ module_name, fn_name, _ = EXTRACTORS[kind]
+ # The package name is derived, not written: the Makefile takes PKG from the
+ # directory name so the folder can be copied anywhere and renamed, and a
+ # literal "docgen" here would quietly undo that.
+ root_pkg = __package__.rsplit(".", 1)[0]
+ fn = getattr(import_module(module_name, package=root_pkg), fn_name)
+
+ kwargs = {"identity": identity or str(source)}
+ if kind in TAKES_EXCLUDE:
+ kwargs["exclude"] = exclude
+ g = fn(source, **kwargs)
+
+ ir = g.to_dict()
+ measured = ir["meta"].get("larder")
+ if measured is None:
+ # Not fatal. An extractor that cannot count its input still produces a
+ # book; what it does not get to do is pretend it measured one.
+ return ir, None
+ return ir, Larder.from_dict(measured)
+
+
+def spec_from(book: Book, ir: dict) -> dict:
+ """The book's ledger -> a notebook spec, with the two measures at the ends.
+
+ Reuses `notebook.spec`'s step vocabulary rather than inventing one, so
+ `merge()` keeps working and a hand-written overlay can annotate a spine step
+ the same way it annotates any other.
+ """
+ from ..notebook import spec as spec_mod
+
+ steps = [
+ spec_mod._step(
+ "larder", "md", title=f"{book.slug} — what came in",
+ text=(
+ f"`{book.larder.identity}`\n\n**{book.larder.line()}**\n\n"
+ "This is the first step of the book and the only measure of the "
+ "input. Everything below is derived from it, so a number here "
+ "that looks wrong makes everything below it suspect."
+ + (
+ "\n\nCould not be read:\n\n"
+ + "\n".join(f"- `{f['name']}` — {f['error']}"
+ for f in book.larder.failed)
+ if book.larder.failed else ""
+ )
+ ),
+ ),
+ spec_mod._step("prelude", "code", title="Reading a step on its own",
+ code=PRELUDE),
+ ]
+
+ for s in book.steps:
+ if s.id in ("larder", "book") or not s.artifact:
+ continue
+ steps.append(spec_mod._step(
+ f"step-{s.id}", "md", title=s.label,
+ text=f"`{s.artifact}` — {s.bytes:,} bytes" + (f"\n\n{s.note}" if s.note else ""),
+ ))
+ steps.append(spec_mod._step(
+ f"load-{s.id}", "code", code=_load_cell(s.id, s.artifact),
+ ))
+
+ # Where the larder is API-shaped, the generated endpoint walkthrough slots in
+ # as further steps. For a tree of source there are no endpoints and this adds
+ # nothing, which is the correct amount for it to add.
+ if any(n["kind"] in ("endpoint", "operation") for n in ir.get("nodes") or []):
+ generated = spec_mod.from_ir(ir)
+ steps.extend(s for s in generated["steps"] if s["id"] not in ("intro",))
+
+ m = book.measure()
+ # `external` is reported on its own below, so it is dropped here rather than
+ # appearing twice in one line — which is how it read before.
+ summary = " · ".join(f"{v} {k}" for k, v in
+ sorted(m["by_kind"].items(), key=lambda kv: -kv[1])
+ if k != "external")
+ reconciled = "\n".join(
+ f"- {'✓' if r['ok'] else '✗'} {r['claim']} — {r['why']}"
+ for r in book.compare()
+ )
+ steps.append(spec_mod._step(
+ "book", "md", title=f"{book.slug} — what came out",
+ text=(
+ f"**{summary} · {m['edges']} edges · {m['external']} external**\n\n"
+ f"{len(m['artifacts'])} artifact(s), {m['bytes']:,} bytes.\n\n"
+ f"Reconciled against what came in:\n\n{reconciled}\n\n"
+ "The web output is `site/index.html`."
+ ),
+ ))
+
+ return {"version": spec_mod.SPEC_VERSION, "steps": steps}
+
+
+def _load_cell(step_id: str, artifact: str) -> str:
+ """A cell that reads one artifact and prints one fact about it.
+
+ The fact has to suit the artifact. An earlier version printed "nodes, edges"
+ for every JSON file, so `sidebar.json` — which has neither — reported
+ "0 nodes, 0 edges", which is a true sentence about the wrong thing and worse
+ than saying nothing.
+ """
+ if not artifact.endswith((".json", ".svg", ".md")):
+ # A directory, e.g. the explorer.
+ return (
+ f'files = load("{artifact}")\n'
+ 'if files is not None:\n'
+ ' print(f"{len(files)} file(s)")\n'
+ ' print("\\n".join(files[:5]))'
+ )
+ if artifact.endswith(".json"):
+ return (
+ f'data = load("{artifact}")\n'
+ 'if data:\n'
+ ' if "nodes" in data:\n'
+ ' print(f\'{len(data["nodes"])} nodes, {len(data.get("edges", []))} edges\')\n'
+ ' else:\n'
+ ' print(", ".join(f"{k}: {len(v) if isinstance(v, (list, dict)) else v}"\n'
+ ' for k, v in data.items()))'
+ )
+ if artifact.endswith(".svg"):
+ # No IPython import, guarded or otherwise. A generated notebook is a
+ # build artifact and has to run wherever it is opened; requiring a
+ # kernel package in order to *load a file* would make the cell fail on
+ # the machine that produced it, which is where this was found.
+ return (
+ f'svg = load("{artifact}")\n'
+ 'if svg:\n'
+ ' print(f"{len(svg):,} bytes of SVG")\n'
+ ' # In Jupyter: from IPython.display import SVG; SVG(svg)'
+ )
+ if artifact.endswith(".md"):
+ return (
+ f'text = load("{artifact}")\n'
+ 'if text:\n'
+ ' print(text[:400])'
+ )
+ return f'print(load("{artifact}") is not None)'
+
+
+def run(kind: str, source, out, *, slug: str | None = None, style: str = "lucid",
+ theme: str | None = None, exclude=(), overlay=None, quiet: bool = False) -> Book:
+ """The whole book, in one process. Returns it; `book.json` is written."""
+ from ..ir import check
+ from ..ops import classify, overview
+ from ..style import Style
+
+ out = Path(out)
+ slug = slug or Path(str(source)).name or "book"
+ steps_dir = out / "steps"
+ steps_dir.mkdir(parents=True, exist_ok=True)
+
+ def say(text):
+ if not quiet:
+ print(text)
+
+ ir, larder = extract(kind, source, exclude=exclude)
+ if larder is None:
+ larder = Larder(kind=kind, identity=str(source), unit="document", seen=0)
+ book = Book(slug=slug, larder=larder, out=out)
+ book.ir = ir
+ say(f" larder {larder.line()}")
+
+ problems = check(ir)
+ if problems:
+ # Recorded as a step rather than raised: a book that cannot be trusted
+ # should exist and say so, because the alternative is that nobody can
+ # see what went wrong.
+ book.step("validate", "the IR did not validate", note="; ".join(problems[:3]))
+ say(f" WARNING IR has {len(problems)} problem(s)")
+
+ p = steps_dir / "ir.json"
+ p.write_text(json.dumps(ir, indent=2) + "\n")
+ book.step("ir", "the graph, extracted", p, note=f"{len(ir['nodes'])} nodes")
+
+ view = overview(ir)
+ p = steps_dir / "view.json"
+ p.write_text(json.dumps(view, indent=2) + "\n")
+ verdict = classify(view)
+ book.step("view", "the default view for this source", p,
+ note=f"{verdict['kind']} — {verdict['why']}")
+
+ style_obj = Style.load(style, theme=theme)
+
+ # The drawing, chosen by structure rather than by the caller. `classify`
+ # already decided; this runs what it named.
+ graph_rel = None
+ if verdict["emitter"] == "erd":
+ from ..emitters.erd import emit as erd_emit
+ p = steps_dir / "graph.svg"
+ p.write_text(erd_emit(view, style_obj))
+ graph_rel, label = "steps/graph.svg", "drawn as an ERD"
+ elif verdict["emitter"] == "index":
+ from ..emitters.index import to_markdown
+ p = steps_dir / "graph.md"
+ p.write_text(to_markdown(view))
+ graph_rel, label = None, "not a diagram — written as a list"
+ else:
+ from ..emitters.dot import emit as dot_emit, render
+ p = steps_dir / "graph.svg"
+ try:
+ p.write_bytes(render(dot_emit(view, style_obj,
+ rankdir=(verdict.get("options") or {}).get("rankdir"))))
+ graph_rel, label = "steps/graph.svg", f"drawn as a {verdict['kind']}"
+ except Exception as e: # noqa: BLE001 - graphviz may not be installed
+ p = None
+ label = f"not drawn — {type(e).__name__}"
+ book.step("graph", label, skipped=str(e)[:200])
+ if p is not None:
+ book.step("graph", label, p, note=verdict["why"])
+
+ from ..emitters.index import to_markdown, to_sidebar
+ p = steps_dir / "index.md"
+ p.write_text(to_markdown(ir))
+ book.step("index", "readable without a diagram", p)
+ p = steps_dir / "sidebar.json"
+ p.write_text(json.dumps(to_sidebar(ir), indent=2) + "\n")
+ book.step("sidebar", "navigation, for whatever renders it", p)
+
+ try:
+ from ..emitters.minimap import emit as mm_emit
+ p = steps_dir / "minimap.svg"
+ p.write_text(mm_emit(ir, style_obj))
+ book.step("minimap", "what is where, read from the colours", p)
+ except Exception as e: # noqa: BLE001
+ book.step("minimap", "minimap not drawn", skipped=f"{type(e).__name__}: {e}")
+
+ try:
+ from ..emitters.explore import write as exp_write
+ exp_write(ir, style_obj, out / "explore")
+ book.step("explore", "navigate on one side, explore on the other",
+ out / "explore")
+ except Exception as e: # noqa: BLE001
+ book.step("explore", "explorer not built", skipped=f"{type(e).__name__}: {e}")
+
+ # -- the last step ----------------------------------------------------
+ from ..emitters.site import write as site_write
+ site_dir = out / "site"
+ site_write(view, style_obj, site_dir,
+ graph=Path(graph_rel).name if graph_rel else None,
+ title=slug, book=book.to_dict())
+ if graph_rel and (out / graph_rel).exists():
+ (site_dir / Path(graph_rel).name).write_bytes((out / graph_rel).read_bytes())
+
+ # close() BEFORE the notebook spec is built, not after. The spec quotes the
+ # book measure, and the book measure only includes the site once the last
+ # step exists — build it the other way round and the notebook says 7
+ # artifacts while book.json says 8, which is exactly what it did.
+ book.close(site=site_dir)
+
+ from ..emitters.notebook import write as nb_write
+ from ..notebook import spec as spec_mod
+ spec, spec_problems = spec_mod.merge(spec_from(book, ir), overlay)
+ nb_write(spec, out / "notebook.ipynb")
+ # Recorded by name and not by size: a notebook cannot report its own byte
+ # count without changing it.
+ book.notebook = "notebook.ipynb"
+
+ for pr in spec_problems:
+ say(f" overlay {pr}")
+ path = book.write()
+
+ m = book.measure()
+ say(f" book {m['nodes']} nodes · {m['edges']} edges · "
+ f"{m['external']} external · {len(m['artifacts'])} artifacts")
+ for r in book.compare():
+ say(f" {'ok ' if r['ok'] else 'LOST'} {r['claim']}")
+ say(f" open {site_dir / 'index.html'}")
+ say(f" {path}")
+ return book
diff --git a/soleprint/atlas2/docgen/book/checks.py b/soleprint/atlas2/docgen/book/checks.py
new file mode 100644
index 0000000..28d874e
--- /dev/null
+++ b/soleprint/atlas2/docgen/book/checks.py
@@ -0,0 +1,305 @@
+"""
+Does *this* book still hold — the third test level.
+
+ python3 -m docgen.book.checks out/book/station
+
+docgen has three levels of test, and they differ by what they assert *about*.
+The distinction is rig's, from `rig/ctrl/selftest.sh`, and it is worth keeping
+because it decides what a failure means:
+
+ make doctor the MACHINE. Never fails; it reports.
+ make check DOCGEN. Exits 1. "docgen no longer does what it says."
+ make check BOOK=
+ THIS BOOK. Exits 1. "this book no longer holds."
+
+The third is the one that reaches a project docgen has never seen. It is also
+where **framework and custom checks live together**, at different levels:
+
+ generated 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.
+ custom hand-written, in the book's own `checks.py`, using the same
+ check/note/skip helpers — so a project's line and a framework
+ line read identically and fail identically.
+
+Same split as the notebook's base and overlay, for the same reason: generation
+alone cannot know what *this* project cares about, and hand-authoring alone rots.
+
+## The discipline these are written in
+
+Carried from rig verbatim, because it is the point of the whole level:
+
+> Each check is ONE decision that has already been made, with the reason above
+> it — not coverage, and deliberately not an exhaustive sweep of use cases. A
+> rule without its reason gets overridden the first time it is inconvenient.
+> Failing one should read as **"you are about to undo this"** rather than
+> "something broke".
+
+## Writing a book's own checks
+
+Put a `checks.py` next to `book.json`:
+
+ def checks(book, check, note, skip):
+ note("what this project will not give up")
+
+ # Payments moved once already and the move broke three dashboards.
+ # If it is not here, something renamed it again.
+ check("payments is still a module", True,
+ any(n["id"] == "app.payments" for n in book.ir["nodes"]))
+
+`book` carries `.data` (the parsed `book.json`), `.ir` (the extracted graph),
+and `.dir`. `check` takes (name, expected, actual) — expected first, because a
+failure report is only useful if it says what was wanted.
+"""
+
+import json
+import sys
+from pathlib import Path
+
+from . import FIRST, LAST
+
+
+class Loaded:
+ """A book read back off disk, for checking rather than building."""
+
+ def __init__(self, directory):
+ self.dir = Path(directory)
+ path = self.dir / "book.json"
+ if not path.exists():
+ raise FileNotFoundError(
+ f"{path} does not exist — is {self.dir} a book directory?\n"
+ "Build one with: python3 -m docgen.book --root -o "
+ )
+ self.data = json.loads(path.read_text())
+
+ ir_path = self.dir / "steps" / "ir.json"
+ self.ir = json.loads(ir_path.read_text()) if ir_path.exists() else None
+
+ nb_path = self.dir / (self.data.get("notebook") or "notebook.ipynb")
+ self.notebook = json.loads(nb_path.read_text()) if nb_path.exists() else None
+
+ @property
+ def larder(self) -> dict:
+ return self.data.get("larder") or {}
+
+ @property
+ def measure(self) -> dict:
+ return self.data.get("book") or {}
+
+
+class Report:
+ """rig's reporting shape: sections, one line per decision, nothing aborts."""
+
+ def __init__(self):
+ self.passed, self.failed, self.skipped = 0, [], 0
+
+ def note(self, text: str) -> None:
+ print(f"\n{text}")
+
+ def check(self, name: str, expected, actual) -> bool:
+ if expected == actual:
+ print(f" ok {name}")
+ self.passed += 1
+ return True
+ print(f" FAIL {name}\n expected: {expected!r}\n got: {actual!r}")
+ self.failed.append(name)
+ return False
+
+ def skip(self, name: str, why: str) -> None:
+ print(f" -- {name} ({why})")
+ self.skipped += 1
+
+ def total(self) -> int:
+ print()
+ # stdout is block-buffered when redirected and stderr is not, so the
+ # failure summary printed below would otherwise arrive BEFORE the checks
+ # it summarises — which is how this was found, piping to `tail`.
+ sys.stdout.flush()
+ if not self.failed:
+ print(f"{self.passed} checks passed — this book still holds"
+ + (f", {self.skipped} skipped" if self.skipped else ""))
+ return 0
+ print(f"FAILED — {len(self.failed)} of {self.passed + len(self.failed)}: "
+ f"{', '.join(self.failed)}", file=sys.stderr)
+ print("Read the comment next to the check. A failure here means a decision "
+ "has drifted, not that a tool is broken.", file=sys.stderr)
+ return 1
+
+
+def generated(book: Loaded, r: Report) -> None:
+ """The spine's own assertions. Identical for every book, by design."""
+
+ r.note("the book is bracketed — both ends present")
+ # The one structural promise the whole design makes: an operation is
+ # measured at both ends. A book missing either end is not a short book, it
+ # is a book whose numbers cannot be reconciled against anything.
+ ids = [s["id"] for s in book.data.get("steps") or []]
+ r.check("the first step is the larder measure", FIRST, ids[0] if ids else None)
+ r.check("the last step is the book measure", LAST, ids[-1] if ids else None)
+ r.check("nothing is bracketed twice", [1, 1],
+ [ids.count(FIRST), ids.count(LAST)])
+
+ r.note("what came in was measured, not guessed")
+ larder = book.larder
+ for key in ("kind", "identity", "unit", "seen", "read", "failed"):
+ r.check(f"larder records {key}", True, key in larder)
+ # read is derived, so a stored value that disagrees means somebody wrote it
+ # by hand. This is the same arithmetic ir/validate.py enforces; asserted
+ # again here because a book can be assembled without going through the IR.
+ r.check("read == seen - failed", larder.get("read"),
+ max(0, larder.get("seen", 0) - len(larder.get("failed") or [])))
+ # The one field that could carry a secret out of a database URL.
+ r.check("the identity carries no password", True,
+ ":***@" in larder.get("identity", "") or "@" not in larder.get("identity", ""))
+
+ r.note("the two ends reconcile")
+ reconciled = book.data.get("reconciled") or []
+ r.check("something was reconciled", True, len(reconciled) > 0)
+ for item in reconciled:
+ # Each of these is a claim the book makes about itself. A false one
+ # means the document below is smaller than the source and does not say
+ # so, which is the single failure this level exists to catch.
+ r.check(item["claim"], True, item["ok"])
+
+ r.note("the ledger describes files that exist")
+ for step in book.data.get("steps") or []:
+ artifact = step.get("artifact")
+ if not artifact:
+ continue
+ path = book.dir / artifact
+ # A ledger naming a file that is not there is worse than no ledger: it
+ # is a manifest somebody will build tooling against.
+ r.check(f"{artifact} is where the ledger says", True, path.exists())
+ if path.is_file():
+ r.check(f"{artifact} is {step['bytes']} bytes", step["bytes"],
+ path.stat().st_size)
+
+ r.note("the notebook carries the sequence, with the measures at its ends")
+ if book.notebook is None:
+ r.skip("notebook", "no notebook.ipynb in this book")
+ else:
+ cells = book.notebook.get("cells") or []
+ first = "".join(cells[0]["source"]) if cells else ""
+ last = "".join(cells[-1]["source"]) if cells else ""
+ r.check("its first cell is what came in", True, "what came in" in first)
+ r.check("its last cell is what came out", True, "what came out" in last)
+ # Compiling is not enough — see selftest.py. `json.dumps` writes
+ # `false`/`true`/`null`, which are valid Python *identifiers*, so a
+ # generated body full of them compiles and then raises NameError.
+ ran, failure = _run_cells(cells, book.dir)
+ r.check(f"its {ran} code cells run, not merely compile", None, failure)
+
+ r.note("the web output exists and shows both ends")
+ index = book.dir / "site" / "index.html"
+ if not index.exists():
+ r.skip("site", "no site/index.html in this book")
+ else:
+ html = index.read_text()
+ # The site is the last step precisely because it is the artifact somebody
+ # definitely opens. A page that shows the result without showing what
+ # went in is the thing this whole change corrects.
+ r.check("the page says what came in", True, "what came in" in html)
+ r.check("the page says what came out", True, "what came out" in html)
+ if larder.get("failed"):
+ r.check("the page admits the book is incomplete", True,
+ "This book is incomplete" in html)
+
+ r.note("the book is reproducible")
+ # A timestamp would make two builds of an unchanged larder differ, which
+ # destroys the only useful property a ledger has: that a diff means a real
+ # change. Same rule as the IR's `generated_at`.
+ r.check("no timestamp in the ledger", True, "generated_at" not in json.dumps(book.data))
+
+
+def custom(book: Loaded, r: Report) -> None:
+ """This book's own assertions, if it has any."""
+ path = book.dir / "checks.py"
+ if not path.exists():
+ r.note("this book's own checks")
+ r.skip("custom checks", f"no {path.name} — write one to assert what this project cares about")
+ return
+
+ namespace: dict = {"__file__": str(path), "__name__": "book_checks"}
+ try:
+ exec(compile(path.read_text(), str(path), "exec"), namespace)
+ except Exception as e: # noqa: BLE001 - report, do not traceback
+ r.note("this book's own checks")
+ r.check(f"{path.name} loads", None, f"{type(e).__name__}: {e}")
+ return
+
+ fn = namespace.get("checks")
+ if not callable(fn):
+ r.note("this book's own checks")
+ r.check(f"{path.name} defines checks(book, check, note, skip)", True, False)
+ return
+
+ try:
+ fn(book, r.check, r.note, r.skip)
+ except Exception as e: # noqa: BLE001
+ r.check(f"{path.name} ran to completion", None, f"{type(e).__name__}: {e}")
+
+
+def _run_cells(cells: list, cwd: Path) -> tuple[int, str | None]:
+ """Execute the notebook's code cells from the book directory."""
+ import contextlib
+ import io
+ import os
+
+ ns, ran, failure = {}, 0, None
+ previous = os.getcwd()
+ try:
+ os.chdir(cwd)
+ with contextlib.redirect_stdout(io.StringIO()):
+ for c in cells:
+ if c.get("cell_type") != "code":
+ continue
+ src = "".join(c["source"])
+ if "urlopen" in src or ("call(" in src and "def call" not in src):
+ # Anything that would reach the network is compiled, not run.
+ try:
+ compile(src, c["id"], "exec")
+ ran += 1
+ except SyntaxError as e:
+ failure = f"{c['id']}: {e}"
+ break
+ continue
+ try:
+ exec(compile(src, c["id"], "exec"), ns)
+ ran += 1
+ except Exception as e: # noqa: BLE001 - any failure is the finding
+ failure = f"{c['id']}: {type(e).__name__}: {e}"
+ break
+ finally:
+ os.chdir(previous)
+ return ran, failure
+
+
+def main(argv=None) -> int:
+ import argparse
+
+ p = argparse.ArgumentParser(
+ prog="python3 -m docgen.book.checks",
+ description="Check one book: the spine's assertions, then its own.",
+ )
+ p.add_argument("book", type=Path, help="A book directory (holding book.json).")
+ p.add_argument("--only", choices=("generated", "custom"),
+ help="Run one level rather than both.")
+ args = p.parse_args(argv)
+
+ try:
+ book = Loaded(args.book)
+ except (FileNotFoundError, json.JSONDecodeError) as e:
+ print(f"Error: {e}", file=sys.stderr)
+ return 1
+
+ print(f"{book.dir} — {book.data.get('slug', '?')}")
+ r = Report()
+ if args.only != "custom":
+ generated(book, r)
+ if args.only != "generated":
+ custom(book, r)
+ return r.total()
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/soleprint/atlas2/docgen/book/larder.py b/soleprint/atlas2/docgen/book/larder.py
new file mode 100644
index 0000000..e8ceff0
--- /dev/null
+++ b/soleprint/atlas2/docgen/book/larder.py
@@ -0,0 +1,195 @@
+"""
+What went in — the first step of every book.
+
+A **larder** is the stocked source together with what connects it: a repo, a
+live database, an OpenAPI endpoint, a HAR capture. Deliberately not "bucket".
+A bucket is somewhere bytes sit; a larder is stocked from outside, has an
+inventory, and goes stale. All three of those are properties worth measuring,
+and they are the reason this file exists.
+
+## Why a measure at all
+
+Every extractor knew what it read and threw the number away. `Meta` carried
+`source` and `root` — which extractor ran, and what it was pointed at — and
+nothing about what was actually consumed. So a run that read 45 of 47 files
+produced exactly the same document as one that read all 47, and the diagram
+looked complete either way.
+
+That is the failure this prevents, and it is the same failure the IR already
+guards against one level down: an unresolved name becomes an `external` node
+rather than being dropped, because *silently losing a thing is worse than
+recording an unresolved one*. A larder measure is that rule applied to the
+input as a whole.
+
+## seen, failed, read
+
+ seen how many units the larder offered
+ failed the ones that could not be consumed, by name, with the reason
+ read seen - len(failed), derived and never stored
+
+Three fields where two numbers would do, on purpose: `read` as a stored value
+invites the question "does it include the failures", and every reader answers it
+differently. Derived, there is nothing to get wrong.
+
+## Redaction
+
+`identity` is the one field in this whole tool that can carry a credential —
+a database DSN has the password in it. So it is scrubbed here at construction,
+and `ir/validate.py` sweeps for the scrub having worked. That is the same
+discipline as `VISUAL_KEYS`: the architectural rule is a check, not a convention.
+
+Paths are left as the caller gave them rather than resolved. An absolute path is
+not a secret but it is machine-specific, and `meta.root` is already a bare name
+for exactly that reason.
+"""
+
+import re
+from dataclasses import dataclass, field
+
+# What `seen` counts, per source type. Closed, like the IR's `kind` vocabulary:
+# a larder that counts something with no name here is a larder nobody can
+# compare against another one.
+UNITS = ("file", "table", "path", "entry", "document")
+
+# Query-string and connection-string keys whose value is a secret. Matched
+# case-insensitively, because ODBC writes `Password=` and URLs write `password=`.
+SECRET_KEYS = (
+ "password", "passwd", "pwd", "secret", "token", "access_token", "refresh_token",
+ "api_key", "apikey", "key", "sig", "signature", "auth", "credentials",
+)
+
+MASK = "***"
+
+
+def _count(label: str, n: int) -> str:
+ """Agree a label with its count, both directions.
+
+ Two directions because both arise: the closed `UNITS` are singular and need
+ pluralising, while `extra` keys are written plural by the extractor that
+ knows them ("packages", "hosts") and need singularising at one. Naive `+ "s"`
+ gives "entrys"; naive nothing gives "1 hosts".
+ """
+ if n == 1:
+ return label[:-3] + "y" if label.endswith("ies") else label.rstrip("s") or label
+ if label.endswith("y"):
+ return label[:-1] + "ies"
+ return label if label.endswith("s") else label + "s"
+
+
+def redact(identity: str) -> str:
+ """Strip credentials out of a path or connection string.
+
+ Three shapes, which is all of them in practice:
+
+ postgresql://user:hunter2@host:5432/db -> postgresql://user:***@host:5432/db
+ https://api/x?token=abc123 -> https://api/x?token=***
+ Driver=x;Server=y;Password=hunter2; -> Driver=x;Server=y;Password=***;
+
+ The user, host, port and database survive. Those are what someone reading
+ the measure needs in order to recognise which larder this was, and none of
+ them is a secret.
+ """
+ if not identity:
+ return identity
+
+ # scheme://user:secret@host — the password is between the first colon after
+ # the scheme and the last @ of the authority.
+ identity = re.sub(
+ r"(?P[a-zA-Z][a-zA-Z0-9+.\-]*://)(?P[^:/@\s]+):(?P[^@/\s]*)@",
+ lambda m: f"{m.group('scheme')}{m.group('user')}:{MASK}@",
+ identity,
+ )
+
+ # key=value, in a query string or a semicolon-delimited connection string.
+ keys = "|".join(re.escape(k) for k in SECRET_KEYS)
+ identity = re.sub(
+ rf"(?i)\b(?P{keys})(?P\s*=\s*)(?P[^&;\s]*)",
+ lambda m: f"{m.group('key')}{m.group('sep')}{MASK}",
+ identity,
+ )
+ return identity
+
+
+@dataclass
+class Larder:
+ """What one source offered, and how much of it was consumed.
+
+ Lands in `meta.larder`. Never in `nodes` or `attrs` — the IR is structure,
+ and provenance is meta. That split is the same one that keeps colour out.
+ """
+
+ kind: str # which extractor stocked it
+ identity: str # path or redacted DSN
+ unit: str # one of UNITS
+ seen: int = 0
+ failed: list[dict] = field(default_factory=list)
+ extra: dict = field(default_factory=dict) # per-source facts
+
+ def __post_init__(self):
+ self.identity = redact(self.identity)
+ if self.unit not in UNITS:
+ raise ValueError(
+ f"larder unit {self.unit!r} is not one of {UNITS} — "
+ "a unit nobody else uses cannot be compared against another larder"
+ )
+
+ @property
+ def read(self) -> int:
+ """`seen` minus what failed. Derived, so it cannot disagree with itself."""
+ return max(0, self.seen - len(self.failed))
+
+ def fail(self, name: str, error: str) -> None:
+ """Record a unit that could not be consumed, by name and reason."""
+ self.failed.append({"name": name, "error": error})
+
+ def to_dict(self) -> dict:
+ """Key order fixed so an unchanged larder serialises byte-identically."""
+ out = {
+ "kind": self.kind,
+ "identity": self.identity,
+ "unit": self.unit,
+ "seen": self.seen,
+ "read": self.read,
+ "failed": [{"name": f["name"], "error": f["error"]} for f in self.failed],
+ }
+ if self.extra:
+ out["extra"] = {k: self.extra[k] for k in sorted(self.extra)}
+ return out
+
+ @classmethod
+ def from_dict(cls, data: dict) -> "Larder":
+ """Round-trip. `read` is dropped: it is derived, not carried."""
+ return cls(
+ kind=data["kind"],
+ identity=data["identity"],
+ unit=data["unit"],
+ seen=data.get("seen", 0),
+ failed=list(data.get("failed") or []),
+ extra=dict(data.get("extra") or {}),
+ )
+
+ def line(self) -> str:
+ """The one-line human form — the notebook's first cell, and the CLI.
+
+ Reads as a sentence because it is the first thing anyone sees about a
+ book, and "47 files read, 2 failed" is the fact that decides whether the
+ rest of the document is worth trusting. Which is also why the grammar
+ gets attention it would not otherwise deserve: "2 entrys read, 1 hosts"
+ reads as a machine talking, and a measure nobody reads is not a measure.
+ """
+ parts = [f"{self.read} {_count(self.unit, self.read)} read"]
+ if self.failed:
+ parts.append(f"{len(self.failed)} failed")
+ for key in sorted(self.extra):
+ value = self.extra[key]
+ label = key.replace("_", " ")
+ if isinstance(value, int):
+ parts.append(f"{value} {_count(label, value)}")
+ else:
+ parts.append(f"{value} {label}")
+ return f"{self.identity} — " + ", ".join(parts)
+
+
+def of(kind: str, identity: str, unit: str, **extra) -> Larder:
+ """Shorthand for the common case: `of("db", dsn, "table", dialect=...)`."""
+ return Larder(kind=kind, identity=identity, unit=unit, extra=extra)
diff --git a/soleprint/atlas2/docgen/docs/img/architecture.svg b/soleprint/atlas2/docgen/docs/img/architecture.svg
index e4139be..efe5476 100644
--- a/soleprint/atlas2/docgen/docs/img/architecture.svg
+++ b/soleprint/atlas2/docgen/docs/img/architecture.svg
@@ -4,57 +4,304 @@
-