# odd — design Keep per-switch HTML documentation continuously up to date by hooking into opam's session lifecycle and rebuilding only the packages each session actually changed, using `odd-driver` (from the odoc driver, opam mode) as the per-package generator. ## Goals - After any successful `opam install` / `opam upgrade` / `opam remove`, the switch's HTML docs reflect the new state, with no manual step. - Incremental: only packages (re)built or removed in the session are reprocessed. - Per-switch: all state and output live under the switch prefix and vanish with `opam switch remove`. - Fail-safe: a documentation failure never causes the opam invocation to fail. ## Non-goals - Multi-universe handling (docs.ocaml.org's problem). A switch is a single coherent universe: exactly one version of each package, all built together — which is why opam mode drops voodoo mode's universe/blessed machinery and uses the flat `/` layout. - Parallel doc building across packages (possible later; see Open questions). ## The generator: `odd-driver` A variant of `odoc_driver` (opam mode) designed for exactly this job. It is **vendored** into this repo under `driver/` (the whole `odoc_driver_lib` plus the opam-mode entry point, built and installed as the `odd-driver` binary alongside `odd`) — a temporary measure pending upstreaming of the changes into `odoc-driver`; until then `odd` carries its own copy so it is self-contained. Per invocation it documents **one package installed in the current switch**: - Package file lists come from opam's own record, `/.opam-switch/install/.changes` — no prepared input tree, no file copying; installed artifacts are read in place. - The switch is discovered by shelling out to `opam switch show` / `opam var prefix` (both read-only operations). - `--odoc-dir`, `--odocl-dir` and `--html-dir` all default to `/odoc`. Intermediate `.odoc`/`.odocl` files and HTML share that tree, with each package's output directly under `odoc//` (flat layout, same as plain `odoc_driver`). This is an odd-owned tree, kept separate from opam's own `/doc`, where packages install their own documentation. - Previously built dependencies are discovered by scanning `--odoc-dir` for the `.odoc_pkg_marker` / `.odoc_lib_marker` files written on earlier runs — this is what makes incremental, package-at-a-time building work. - Like the voodoo driver, it must be run package-by-package, **with each package's dependencies already compiled** in `--odoc-dir`. Ordering is our job (see Ordering). So the wrapper tool's responsibilities reduce to: ordering the work and invoking the driver once per stale package (recording what changed and cleaning up removals are done directly by the coreutils hooks, see below). ## Components ### 1. opam hooks (configured in `~/.opam/config`, shipped via opamrc) Three wrapper fields drive everything. The two per-package hooks are plain coreutils — crucially they invoke **no odd binary** — so they keep working even in a switch where odd isn't installed (or is mid-upgrade, or where a *global* hook fires for a switch that never had it). All real work happens once per session, in the one hook that does need the binary. ``` post-install-commands: [ [ "sh" "-c" "mkdir -p \"$1/odoc/$2\" && touch \"$1/odoc/$2/.odd-stale\"" "--" "%{prefix}%" "%{name}%" ] { error-code = 0 } ] post-remove-commands: [ [ "rm" "-rf" "%{prefix}%/odoc/%{name}%" ] { error-code = 0 } ] post-session-commands: [ [ "%{hooks}%/odd" "sync" "--prefix" "%{prefix}%" ] { success } ] ``` - **post-install** marks the package stale by `touch`-ing `odoc//.odd-stale` (after `mkdir -p`-ing the dir). It fires for every install action including same-version rebuilds — which matters, because a dependency-triggered rebuild changes the `.cmti`s but is invisible to session-level `%{new}%`/`%{removed}%`. A marker is a single empty file per package, so opam's parallel package actions can't corrupt anything (distinct files; `touch` is idempotent). **The set of markers is the work list** — no ordering or content is implied (order is recomputed at sync time, see Ordering below). - **post-remove** deletes the package's doc subtree outright. Removal needs no marker and no deferral: the deletion *is* the action, done immediately and binary-free. (This also clears any stale marker that lived in that subtree.) - **post-session** runs `odd sync`, the one hook that needs the binary. It always exits 0: doc failures are logged, never propagated, because opam aborts the invocation with a configuration error if a session hook fails (`opamSolution.ml`, post-session handling). If odd is absent, this hook simply doesn't run; the markers persist and are drained by the next session that does have it — deferred, never lost. ### 2. State and layout (all under `$OPAM_SWITCH_PREFIX`) ``` var/cache/odd/ log # sync output, since hooks must stay quiet lock # guards a manual sync against a hook-invoked one odoc/ / .odd-stale # marker: post-install touched it, sync rebuilds ... # per-package odoc, odocl and HTML (driver defaults) index.html # switch-wide landing page (ours) odoc-search/... # driver support files, search assets ``` The driver's defaults are taken as-is: one `odoc/` tree per switch holding both intermediates and HTML, separate from opam's own `/doc`. The work list lives in that tree too — a `.odd-stale` marker file inside each stale package's dir — so the post-install hook can write it without odd. Our only other state is the log and lock. ### 3. The `sync` step Runs once at session end, under opam's switch lock (post-session executes inside the opam invocation), so no extra locking is needed against concurrent opam sessions. Take a private lock file anyway to guard against a manually-invoked `sync` racing a hook-invoked one. The hook environment is the switch environment, so the driver's `opam switch show` resolves to the right switch; being read-only, the nested opam calls don't contend with the lock the surrounding session holds. 1. **Collect the stale set**: the package names whose `odoc//` holds a `.odd-stale` marker, intersected with the installed set. (A marker for a no-longer-installed package can only survive an install+remove in the same session — the remove hook's `rm -rf` normally clears it — so tidy it away.) Removals need no handling here: the post-remove hook already deleted the docs. 2. **Compute dependency order** over the stale set (see Ordering). 3. **For each stale package, in order**: delete `odoc//` (a clean build — the flat layout has no version in the path, so this prevents files from modules that no longer exist surviving from a previous version, and it removes the marker), then run `odd-driver --actions all` (all directory options left at their switch defaults). On success the marker stays gone; on failure re-create it (`mkdir -p` + `touch`) so the next session retries. 4. **Regenerate the top-level index** *every session*: a landing page listing all installed packages with built docs (directory listing of `odoc/`, filtered). Doing this unconditionally is what lets a session that only *removed* packages drop them from the page — there's no removal marker to trigger on. The write is skipped when the content is unchanged, so idle sessions stay cheap. The per-package pages and redirects are the driver's job; only this one page is ours. ### 4. Ordering `odd-driver` must see a package's dependencies already compiled in `--odoc-dir`, so stale packages are processed in dependency order. Two facts make this tractable: - **The stale set is closed under reverse dependencies.** If A's rebuild could affect B's docs, opam rebuilt B too (that is what triggers recompilation), so B was marked stale by the post-install hook. We never need to add packages to the work list ourselves. - **Order is computed at sync time, never inferred from the markers.** The markers are an unordered set (filesystem entries with no meaningful timestamps to trust), so order is always recomputed from package metadata — necessary anyway, since a marker left over from a failed earlier session can coexist with a freshly touched one for its dependency. The graph is built from opam's installed-package metadata: `$OPAM_SWITCH_PREFIX/.opam-switch/packages/./opam` — the opam file *as installed*, correct for pins and local packages. Parse with the `opam-format` library (never by hand): - collect every package name in `depends:` via `OpamFormula.fold_left`, ignoring version constraints and disjunction structure; - **exclude** dependencies marked `{post}` — they are allowed to be circular and opam itself excludes them from build order; - **include** `depopts:` — an installed optional dependency influenced the build, hence the docs; - intersect the collected names with the installed set (the directory listing of `.opam-switch/packages/` is that set). Whichever disjunct or optional dep is installed is the one that was used. Spurious edges from installed `with-test`/`with-doc` deps only constrain order; they cannot make it wrong. Topologically sort `stale` against this graph (edges to non-stale packages are dropped — their docs are already current in `--odoc-dir`). A cycle (shouldn't happen with `{post}` excluded) is logged and broken arbitrarily rather than failing the run. ### 5. The compiler / stdlib The compiler's libraries (stdlib, compiler-libs, threads…) live under `lib/ocaml` and everything depends on them, so they must be documented first whenever they change. In our favour, they are installed by an ordinary package (`ocaml-base-compiler` / `ocaml-variants` / `ocaml-system`) whose `.changes` file covers `lib/ocaml`, so `odd-driver`'s normal discovery path applies — no synthetic package needed, just the ordinary ordering edge, and a compiler change stales the whole world (correct: opam rebuilds the world anyway). Needs verification early in implementation: that the driver's library discovery (META-less lib-dir scanning) handles the `lib/ocaml` layout, and how the `ocaml` wrapper package (which installs almost nothing itself) behaves — possibly just a no-op build to skip. ## Tool shape One OCaml executable, `odd`, with subcommands: - `odd sync` — the session worker described above (the post-session hook). - `odd rebuild [--all | ...]` — manual escape hatch: mark packages (or everything installed) stale and run sync. `--all` works on pre-existing switches because the installed-package metadata is always present, whether or not hooks were configured at install time. - `odd setup` — write the three wrapper fields into `~/.opam/config` with `opam option --global` (idempotent). There is deliberately **no** `record` subcommand: recording a change (a `touch`) and cleaning up a removal (an `rm -rf`) are plain coreutils in the hooks, so they never depend on odd being installed. Dependencies: `opam-format` (opam file parsing), `bos`, `fpath`, `cmdliner`. The vendored driver (`driver/`) pulls in its own cone (eio, progress, yojson, ocamlfind, logs, sexplib, ppx_sexp_conv, and the `odoc` / `odoc-md` / `sherlodoc` binaries it drives); `odd sync` still invokes `odd-driver` as a subprocess, so the driver's runtime cost stays in a separate process. `odd-driver` is built and installed by this package, so it is always on `PATH` next to `odd` (overridable with `--driver` / `$ODD_DRIVER`). Distribution: an opamrc adding the three `*-commands` fields for `opam init --config`; `odd setup` for retrofitting existing roots. ## Failure handling summary | Failure | Behaviour | |---|---| | package build fails | hooks filtered on `error-code = 0` / `{ success }`; the package isn't marked stale, no sync | | odd not installed in the switch | post-install/post-remove still work (coreutils); markers accumulate, drained by the next session that has odd — deferred, never lost | | doc build fails for one package | logged; its marker is re-created; later packages still attempted (their deps' docs may be stale — accepted, fixed on retry) | | sync interrupted | markers intact (a marker is only cleared by a successful build); next session resumes | | `odd-driver` not found (only if `--driver` points elsewhere; it ships with `odd`) | sync logs and exits 0; markers survive until the driver is available | | docs failure overall | `sync` exits 0 regardless; opam reports its own success untouched | ## Open questions - **Parallelism**: stale-set topo order admits parallel builds of independent packages; `odd-driver` already parallelises units internally (`nb_workers`), which is probably enough for session-sized work lists. - **Search/occurrences**: the driver emits per-package occurrence files and json search indexes; merging them into a switch-wide search index is a later `sync` step. - **Driver self-staleness**: when the switch's odoc/driver itself is upgraded, every package's intermediates were produced by the old odoc. The closure property covers packages opam rebuilt, but odoc version changes may warrant a full `rebuild --all` — detectable by recording the driver version used per run.