Skip to content
semantic-namespacePublic

About

Structural review of Clojure changes. Instead of lines, a change is named by the form it lives in

Resources

Stars

10 stars

Watchers

0 watching

Forks

Latest commit

 

History

77 Commits

Folders and files

Repository files navigation

diff

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.

What it looks like

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.

Ordered by risk

The same pull request ordered by file.

Ordered by file

Use

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.

Local review UI

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/k move between rows, v marks the open one viewed and opens the next, b toggles the base side, c writes a note on it, u goes 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 u or 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.

Running it as a daemon

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.

What a change touches

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.jdbc is postgres, carmine is redis, clj-http is 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.

Derived and inferred

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.

Host pages

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.

MCP server

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.

Tests

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.

Scope

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.

About

Structural review of Clojure changes. Instead of lines, a change is named by the form it lives in

Resources

Stars

10 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages