Structural review of Clojure changes. Instead of lines, a change is named by the form it lives in:
■ core/src/atlas/registry.cljc [semantic]
defn compile!
~ arity 1 › let 2 › binding dev-id-conflicts [extracted → dev-id-conflicts]
defn dev-id-conflicts ⇠ extracted from defn compile! › arity 1 › let 2 › binding dev-id-conflicts
+ new form
drift: replaced keep 3 › fn › body › body › test - (next cids) + (and dev-id (some …
drift: replaced keep 3 › fn › body › body › body 1 › key :winner - (last cids) + (conj …
drift: added sort-by 4 + (sort-by str)
Each file gets one verdict: semantic, rename-only, comments-only or
whitespace-only. Inside a semantic file, every change is a path through the
code (binding, clause, argument, body step) with the old and new expression,
the sub-expressions that survived, moves between slots, and extractions of an
existing expression into a new function, including what drifted on the way and
which locals were renamed. A form that changed visibility, was wrapped by a new
macro, or was renamed and rewritten shows as one change (⇠ was …), not as a
removal and an addition.
A pull request of metosin/malli,
ordered by risk. Each changed form is one row: a one-line summary of what
changed, at most one risk tag (new I/O, a changed signature, or how many forms
in other namespaces call it), its file and a Viewed box. The open row shows its
head source with the change marked, and links to the other changed form it
calls, both on the "changed here" line and in the code. Files that are not
Clojure come last. j/k move, v marks viewed and opens the next, and the
review bar at the bottom posts the review to GitHub.
The same pull request ordered by file.
Babashka, no install beyond the repo:
bb sdiff text <repo> <base> <head>
bb sdiff edn <repo> <base> <head>
bb sdiff html <repo> <out.html> <github-url|-> (<base> <head> <num> <title>)+
Ranges are diffed from the merge base of base and head, which is what a
pull request shows. For a squash-merged commit C, pass C^ C.
GitHub pull requests are read through the gh CLI with your own credentials,
without a clone:
bb sdiff pr <owner/repo#N|url> [text|edn|html <out.html>]
bb sdiff review <owner/repo#N|url> <notes.edn> [--post]
review turns notes written against form paths into a GitHub review. A notes
file looks like this:
{:verdict :request-changes
:summary "The extraction changes behaviour, see below."
:notes [{:file "core/src/atlas/registry.cljc"
:form "defn compile!"
:at "arity 1 › let 2 › binding dev-id-conflicts"
:body "Was the new :winner value intended?"}]}It prints where each note lands, inline on a head-side line or in the review
body when GitHub would refuse it inline, then the payload. With --post it
asks before posting as you.
edn is the contract for other tools: the report map with every node as
{:src :row :col :end-row :end-col}, so a consumer can print a form, anchor a
review comment to a head-side line, or re-read the source. html is a
self-contained page with the whole form shown and the changes marked.
As a library, sdiff.core/file-report takes a path and two source strings,
sdiff.git/report takes a repo and two refs, sdiff.edn/report->edn makes the
result plain data.
bb sdiff serve [port] # http://127.0.0.1:7878/
Open a pull request by reference or URL. The page lists the changed forms as rows, ordered by risk, by file or by the calls between them. Each row has a one-line summary of the change and at most one risk tag. One row is open at a time: its head source with the changes marked, red and green, and on demand the base side, its callers and the structural change paths.
- Keys.
j/kmove between rows,vmarks the open one viewed and opens the next,btoggles the base side,cwrites a note on it,ugoes back. - Related changes. A changed form that calls, or is called by, another
changed form of the PR links to it, both on a "changed here" line and on the
names in its code. Following a link opens that form, and
uor the browser's back returns. A URL ending in#form-…opens on that form. - Other files. Files that are not Clojure come last, each with its status, size, a link to its diff on GitHub and the patch GitHub sent (encrypted, generated and binary files show the link only).
- Viewed. Each row, Clojure or not, has its own Viewed tick, kept on this machine and counted in the progress bar. A form you marked viewed shows "changed" when a later push changes its code, and keeps its mark through a rename.
- Review. The bar at the bottom holds the verdict, the summary and your notes, a preview that shows where each note lands and the exact payload, and a post button that asks before sending.
It posts through gh as you. The server listens on 127.0.0.1 only, and every
write needs a token printed into the page, so other sites in the browser can't
use it. Viewed state, unsent notes and settings live in
~/.local/state/sdiff/ (SDIFF_STATE_DIR overrides it), so they follow you
across browsers. Settings, in the review panel: fold forms you mark viewed, fold
files viewed on GitHub, mark a file viewed on GitHub once all its forms are
viewed (off by default, it writes as you), fold files that only change
formatting, comments or names, always show the base side, show the key hints,
and skip the dependency analysis.
bb sdiff serve 7878 --host <address> serves on another interface, such as a
VPN address. Anyone who can reach that address can then read PRs and post
reviews as you.
systemd/sdiff.service runs the UI as a user service, so it survives logouts,
reboots and crashes. Edit WorkingDirectory, the path to bb and the serve
arguments to match your machine, then:
cp systemd/sdiff.service ~/.config/systemd/user/
systemctl --user daemon-reload
systemctl --user enable --now sdiff
loginctl enable-linger $USER # start at boot, without a login
systemctl --user restart sdiff picks up a new checkout, journalctl --user -u sdiff -f follows its log. The service runs gh as you, so gh auth status
must already work for your user.
Beside the structural diff, the review page analyses the whole repository at
the merge base and at the head with clj-kondo,
fetching each commit's Clojure sources once through gh (a tarball, cached in
~/.cache/sdiff/ and swept after 14 days unused). From the two call graphs it
says, for each changed form:
- who calls it at head, how many of them from other namespaces, and how many are tests;
- the calls, libraries and kinds of I/O it gained or lost. I/O is read from the
libraries a form reaches through its calls, protocol methods with a single
implementation, and functions generated by HugSQL:
next.jdbcis postgres,carmineis redis,clj-httpis http, and so on; - namespaces that start requiring each other, and new namespace cycles.
That is what the risk order uses: a change that now reaches I/O, changes a
function's arguments, or has callers in ten or more forms of other namespaces
comes first. The analysis is heuristic: functions passed as values, protocols
with several implementations and calls by keyword are not followed, unless a
bridge says how. sdiff.deps/add-bridge! adds edges a call graph cannot see;
keyword-bridge covers the common case of a keyword defined in one place and
used elsewhere, and clojure.spec definitions are bridged by default. The
I/O table, test paths, ignored namespaces and code generators can be set per
repository in ~/.config/sdiff/deps.edn, keyed by owner/repo.
Everything on the page is one of two kinds, and labelled. Derived content
comes from a tool named on the label: the structural changes from sdiff, and
whatever decorations a host registers beside each changed form or at the top
of the page (sdiff.decorate/add-form-decorator!, add-header-decorator!,
use-context!). A host that varies its derived facts by request declares the
parameter with sdiff.decorate/add-param!; sdiff never interprets it. The
declared parameters present in the request (?against=main, or params in a
POST body) are bound as sdiff.decorate/*params*, a map of string to string,
while the context builder and the decorators run, and are kept in every link
sdiff builds. Annotations and views are stamped with the parameters they were
made under, and the page labels those made under other ones ("made with
against=main") without hiding them. Viewed marks, notes, verdict and summary
stay shared. The atlas review module registers decorations from a
registry: the entity a form declares with its contract delta, who depends on
it, which data keys it mentions. Inferred content is an annotation a
reviewer or a model attached to a form, an entity or the page, with its author
and the derived facts it rests on. POST /annotate stores them, the annotate
MCP tool posts them for you, and a panel setting hides the inferred layer.
A host that serves the review UI can add pages of its own with
sdiff.serve/add-route! (method, path, title, f) before calling
sdiff.serve/start!. f takes the ring request, with :params holding the
declared request parameters (also bound as sdiff.decorate/*params*), and
returns either a ring response, a map with :status, which is passed through,
or hiccup, which sdiff renders under title with the stylesheets of the other
pages. A GET route is listed on the index page. Built-in paths cannot be
registered; unknown paths are 404 and an exception in f is a 500.
The same operations as MCP tools (structural-diff, review-draft,
post-review, annotate, view), over stdio, on the JVM (plumcp does
not run on babashka). post-review is annotated as a destructive write, so clients ask before calling it.
It acts with the gh credentials of whoever runs it.
structural-diff, annotate and view take an optional params argument in
query-string form (against=deployed%2Fdev), passed to the review server,
which reads the parameters its host declared. It is separate from base,
which is the git merge base for a local range. view composes a named view of
a PR's page from EDN and returns its URL.
claude mcp add sdiff -- bash -c 'cd /path/to/diff && exec clojure -M:mcp'
Set SDIFF_MCP_DEBUG=1 to log MCP
traffic to stderr.
bb test
The fixtures are the changed Clojure files of three merged
semantic-namespace/atlas pull
requests at merge base and head (test/fixtures/atlas.refs): #7 for an
extraction with drift, #4 for a move into a binding and a dropped reader
conditional, #2 for renames rolled up across files. Behaviours no atlas PR
exercises yet are covered by small inline sources in the test.
The structural diff is syntactic, and the dependency analysis follows only what clj-kondo can resolve. Neither knows what a form means in a given codebase. A registry or ontology layer can take the EDN report, or register groupings and decorations on the page, and attach that meaning.

