A live Quil app that lays out and draws UML from an EDN IR. A policy plus a language-specific parser write the topology; this tool displays it, routes the arrows, colors CRAP, and lets you click.

The IR is topology: the namespace tree, classes, and edges. Metrics

(CC, coverage, CRAP, killed/survived) come from .metrics/ snapshots produced

by crap4clj and

clj-mutate. The viewer overlays those

files at load, keyed by namespace + function name. Agents edit the policy, not

the IR. See Policy.

Needs Clojure CLI and Java 21+.

clj -M:ir # policy → examples/uml-viewer.edn

clj -M:run

clj -M:run examples/library.edn

clj -M:run examples/uml-viewer.edn

clj -M:run --helpA tmux session uml-viewer-grok starts interactive Grok in the

examined project's directory (--yolo --trust --rules … plus a launch

prompt). On start it writes a hierarchical policy from that project's

namespaces and regenerates the IR. Type there; Esc is the real TUI interrupt.

Closing the diagram kills that tmux session (and the Terminal attach). That

instance — not every Grok in this repo — also runs clj -M:crap,

clj -M:mutate, and IR generate after later changes. Project-wide rules live

in .grok/rules/uml-viewer.md.

The examined project (and this one) must expose two aliases:

Do not pass --restart (or use :uml-viewer-restart) unless you are that

companion recycling the window after source changes. A stray --restart

skips spawning Grok and leaves a diagram with no agent. The companion

recycles the window by writing :quit-for-restart to

.uml-viewer/to-viewer.edn, waiting for the JVM to exit, then

clj -M:uml-viewer-restart. Do not SIGKILL. Closing the window still kills

Grok.

On a fresh start the canvas stays blank until the companion sends :display,

with Waiting for agent to create diagram. R reloads the current EDN

immediately and does not wait. A missing or unreadable file prints

UML viewer: file not found: … in the inspector instead of throwing.

clj -M:spec

clj -M:cov

clj -M:ir # writes examples/uml-viewer.edn

clj -M:crap # writes .metrics/crap.edn

clj -M:mutate src/uml_viewer/engine/layout.cljThis project's :crap alias uses ../clojure/crap4clj. :mutate pins

clj-mutate by git SHA. Commit

.metrics/ so a clone has numbers without re-running those tools.

Rename or move of a function is a new form: overlay does not match old names.

Layer and component mean the same thing: a namespace grouping (the first segment after the prefix, or a named proposal group).

- First view: namespace components (layers). Dependencies between them collapse to one arrow. Each component lists nested namespaces.

- Double-click a component to open the next level. Esc or the ← label goes up.

- Hover an arrow for a popup of every from -> toit bundles, in any declutter mode. Violating pairs are red.

- The inspector lists proposals. Click one to show it (marked as not in the code). P returns to the ns tree. New Proposal adds a timestamp-named proposal. Right-click to rename or delete. Declutter cycles Declutter arrows / Declutter elements / Declutter classes / Declutter none.

- Double-click a leaf module for its class card.

- The class card names the module (:ns). Click it to open that source file at the top. Hover a member to highlight it; click it to open the same file at the defn. See Source extractors.

- Methods on the card are +public and-private.defn-is not drawn on the class box.

- Abstract classes show a white α in the upper-right; interfaces a white I. Names of rectangles that are not classes (components/layers, interfaces, enumerations, package banners) are italic. Foreign libraries listed in policy are ovals outside the components.

- The main window is resizable.

- Scroll to pan vertically; Shift-scroll (or left/right arrows) for horizontal. Pan can follow arrows that bow past the origin.

- Ctrl+ (or Ctrl+=) zooms in 10%; Ctrl- zooms out 10%; Ctrl+0 restores 100%. Zoom keeps the view center still.

- Regen in the inspector asks the companion to rewrite policy and IR (see Companion mailbox).

- Rreloads the current EDN (the watcher also reloads on save). Overlay re-reads- .metrics/on the next load.

- Escon the class card closes it. Closing the main window exits the app.

This project's diagram is generated. Do not edit examples/uml-viewer.edn.

Edit examples/uml-viewer.policy.edn, then run clj -M:ir (or press Regen).

The parser (LanguageGraph) reads source and emits facts: one class per

project namespace, :require / :use of another project ns as

:dependency, requiring-resolve of a quoted var as :dependency on that

var's namespace, defprotocol as :stereotype :interface, defrecord /

deftype of a protocol as :implements. External :requires and :imports

become foreign classes. Members are not authored — overlay fills them from

.metrics/.

The tree is the namespaces. After :prefix, every . is a nesting

level. uml-viewer.engine.layout is a child of engine.

uml-viewer.clojure-language.source-clojure is a child of clojure-language.

The policy does not assign nses to invented packages. If you want Domain /

Engine / Adapters boxes in the source tree, those segments must exist as

namespaces. To view a grouping that is not in the code, use :proposal

(see Proposed components) — do not rewrite namespaces.

To write a policy for a project:

- Set :srcand:prefixto the project's source root and ns prefix (srcandfooforfoo.bar.baz).

- Set :hierarchical true(or omit:packagesand:diagrams).

- List top-level segments in :order— the first dotted part after the prefix, in the order you want the boxes. Do not invent names.

- List real libraries in :foreignif they should appear as ovals.

- Optionally override a require with :edge-kinds {[:from :to] :association}using the leaf ids (clojure-language.source-clojure, notclojure-language).

- Set :levelsso the generator can mark dependency-rule violations (see Dependency rule).

- Optionally set :proposalto name design components that are not namespaces (see Proposed components).

- Run clj -M:ir(or Regen).

If foo.bar and foo.bar.baz both exist, the bar box lists bar (the

module) and baz (the child). Double-click the component to open that

level; double-click the bar module line for its class card.

Wrong (invented partitions):

:packages [{:id :domain :nses [ir geom source]}

{:id :engine :nses [layout route]}]Right (the ns tree):

{:title "UML viewer"

:src "src"

:prefix "uml-viewer"

:lang :clojure

:out "examples/uml-viewer.edn"

:hierarchical true

:foreign [quil]

:order [main adapters application engine source graph clojure-language domain]

:levels [[domain source graph clojure-language]

[engine]

[application]

[adapters]

[main]]

:edge-kinds {[:engine.compose :engine.layout] :association}}Viewer Grok loop (passed with --rules to the companion session only)

On launch: from the examined directory, write or update the hierarchical policy and regenerate the IR, then wait.

After every later source or policy change: clj -M:crap, clj -M:mutate

on the changed src/ files, then clj -M:ir. Uncovered mutants remaining are

coverage gaps; keep the snapshot and do not re-run the file or force a full

mutation because mutate exited non-zero.

- Add/rename/delete a namespace: the tree updates on clj -M:ir. Put a new top-level segment in:orderif you care about box order.

- Nested nses appear as contents of the parent component.

- “This require is really an association”: one :edge-kindsentry.

- Show a library like quil as an oval: add it to :foreign.

- Do not add :packagesto fake Clean Architecture components. Use:proposalsto view a grouping that is not in the code.

- Preserve :proposalswhen rewriting policy. Do not invent them on launch. If instructed, add a named proposal (default name is a timestamp).

Hand-written sample IRs (e.g. examples/library.edn) are still valid; they

are not generated.

A :dependency edge is violating when it runs from a higher-level

(inner) component to a lower-level (outer) one. That is the Clean

Architecture dependency rule: source-code dependencies point inward.

Evaluation is deterministic given :levels:

- Take the first dotted segment of each end (engine.layout→engine).

- Look up that segment in :levels. Rank is the group's index; smaller is inner / higher-level.

- If both ends have a rank and from-rank < to-rank, the edge is:violating true. Same rank is allowed.:implementsand:associationare never violating. Foreign / unranked ends are not compared.

- Collapsed component arrows keep the flag if any bundled leaf dependency

was violating. Remapping a pair to :associationclears it.

:order is visual box order, not rank. Nesting is not layering: you cannot

infer inner vs outer from the namespace tree alone, so :levels must group

segments that sit at the same architectural level (e.g. domain, source,

and graph). Omit :levels and nothing is marked. If :levels is omitted

and :proposals is set, rank follows the first proposal's component order.

:proposals is a list of named groupings of existing top-level segments.

Those names are not namespaces. Each item is {:id :name :layers [...]}

(:layers here are named components). The as-is diagram stays the ns tree.

The inspector lists the real diagram (the namespace tree) just above

Proposals; click it to return to the tree. Click a proposal to show it

(canvas marked PROPOSAL — not instantiated in code). New Proposal

adds an empty proposal named with a timestamp. Right-click a name to rename

or delete it. Double-click a ns box to drill the real tree.

The Declutter button cycles Declutter arrows (one arrow per component pair per direction) → Declutter elements (also hide nested names, members, and ports) → Declutter classes (also hide classes inside components) → Declutter none.

When a proposal is shown, rank follows that proposal's component order

and violating arrows are re-evaluated. The real diagram uses :levels.

Class boxes show the current view's rank (innermost 0)

at the upper left; the class card repeats Level n. Level 0 is drawn at

the bottom. Good arrows (outer → inner) point down; violating arrows

(inner → outer) point up and stay red. When arrows are collapsed, selecting a class highlights

the component arrows it belongs to. Collapsed components keep their color

and C/M dots; double-click still opens a component.

:proposals [{:id :ccp

:name "2026-09-18 10:30:00"

:layers [{:id :playfield :label "Playfield"

:nses [entities world missiles cities batteries flyers]}

{:id :hosts :label "Hosts" :nses [jvm browser]}]}]Companion Grok must not invent :proposals on launch and must keep them when

updating :order. If instructed, add a named proposal and regenerate the IR.

The viewer draws a violating arrow red, and bold red when a selected

element highlights it. Hand-written IR may set :violating true directly.

The viewer and the companion Grok talk through .uml-viewer/ in the examined

project (gitignored). The file is the mail; tmux is only a doorbell.

Commands are {:id n :op …} with a rising :id. Writes are tmp-then-rename.

Regen in the inspector queues :regen and wakes Grok with literal text, a

150ms pause, C-m, 50ms, then C-j (same timing as SwarmForge). The wake-up

does not contain the command. If Grok is busy, it finishes first, then reads

the mailbox. If tmux is missing, the button still writes the file and the

inspector says the session is not attached.

Generating the IR asks uml-viewer.graph to scan a source tree. :lang

selects the scanner (default :clojure). Register another implementation

with (graph/register! :java my-java-scanner). The scanner must satisfy

LanguageGraph:

Classes are {:id :name :ns :stereotype}. Edges are {:from :to :kind}

(:dependency or :implements). The policy layer is language-neutral.

Clojure (uml-viewer.clojure-language.graph-clojure) is the only

implementation today: it reads ns forms (including prefix lists),

requiring-resolve of a quoted var (including nested calls), defprotocol,

defrecord, and deftype. Java or C need a different parser; do not

special-case languages in policy or ir-generator. Main constructs the

implementation and passes it in.

A hierarchical policy writes one EDN document of all classes and edges

(:hierarchical true). The viewer builds each screen from the namespace tree

at the current drill level. A hand-written IR with :packages (or

:diagrams) is still a static diagram, e.g. examples/library.edn.

Metrics on the class card do not have to be authored. If .metrics/ is

present, the overlay fills CC, coverage, CRAP, killed/survived/uncovered, and any

functions found in the snapshots (including privates). Authored :crap /

:coverage / :ops are the fallback when no snapshot exists.

Overlay keys snapshots by class :ns (the real source namespace). The

generator writes :ns from the scanned ns. Hand-written IR must set :ns

the same way; there is no project-specific fallback.

{:title "Lending library"

:direction :tb

:packages

[{:id :domain

:label "Domain"

:classes

[{:id :book

:name "Book"

:stereotype :class ;; optional: :interface :enumeration :abstract

:fields [{:name "isbn" :type "String"}]

:ops [{:name "find" :args ["isbn"] :returns "Book"}]}]}

{:id :app

:label "Application"

:classes

[{:id :repo

:name "CatalogRepo"

:stereotype :interface

:ops [{:name "get" :args ["isbn"] :returns "Book"}]}]}]

:edges

[{:from :sql-repo :to :repo :kind :implements}

{:from :loan :to :book :kind :association :label "borrows"}]}Optional authored metrics, used when snapshots are missing:

- :crap— a number (- μ) or- {:mu :max :sigma}

- :coverage— ratio 0–1 on a class or op

- :cc,- :killed,- :survived,- :uncovered,- :privateon ops

- :hide-members true— compact box

Package and class color maps CRAP (μ + σ) and mutation score each onto

1–10 using uml-viewer.domain.config cutoffs, averages them, and paints a

0–10 red–green fill. Missing CRAP or mutation data counts as red (grade 1),

not unknown. Parents take the worst CRAP and worst mutation of their

children, and a child with no data is the worst. A C and M dot in

the upper-right show the two scores. The boxes no longer print μ / max / σ.

On the class card, a Crap μ … max … σ … line sits above the table (max is

the worst function in the namespace, not a sum). Column groups are labeled

--crap-- (Crap, CC, Cov) and --mutation-- (killed, survived, uncovered).

The class row shows average CRAP with a μ suffix and omits CC. Killed is

white. Survived and uncovered are green at 0 and red when nonzero. A row

with no mutation sites shows ---no mutation sites--- instead of zeros.

Edge :kind values:

Layout follows Mermaid's three stages:

- Size each class from its text (padding 12).

- Place packages in document order; classes Sugiyama-ranked inside a package; ~40px spacing.

- Route like Mermaid/ELK: ports on facing sides, orthogonal tracks in

the rank gap, short same-rank connections through the stack gap (local U

only when a sibling sits in the way), then stroke with D3 curveBasiscubics. All arrows are solid grey. Paths that pass the target and reverse are rejected.

Clicking a member asks uml-viewer.source for the whole file and a

start line. The IR (and the class card) only supply an identity

map; a language-specific extractor turns that into

{:title :file :body :line}. The source window opens on that file and

scrolls to the member (highlighted). Clicking the module name opens the same

file at the top (:line omitted).

(source/member-source {:lang :clojure

:ns "uml-viewer.engine.layout"

:name "layout"}):lang selects the extractor (default :clojure). Register another

implementation with (source/register! :java my-java-extractor). The

extractor must satisfy LanguageSource:

Clojure (uml-viewer.clojure-language.source-clojure) is the only

implementation today: it maps :ns to src/...clj (or .cljc / .cljs)

and finds the top-level (defn name …) / (defn- name …) so the window can

jump to that line. That locate/line step is not enough for Java or C — those

need a parser or language server, and a richer identity (:class,

:signature, :file). The protocol is the seam; do not special-case

languages in the class card. Main constructs the extractor and passes it to

Core.

Quil stays in adapters.draw and adapters.sketch. The rest of the engine

does not depend on Processing.