unclebob/uml-viewer

Live Quil UML class-diagram viewer driven by EDN

Clojure

106

30 commits

updated Sep 18, 2026

See the code

See what people are saying (1)

SourceMessageScoreDate

Uncle Bob Martin's UML Tool to Manage Grok AI Agents

1

Sep 18, 2026

README

UML viewer

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.

Run

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 --help

A 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:

AliasWhoWhat
:uml-vieweranyoneFresh window. Starts the companion. Waits for :display.
:uml-viewer-restartassociated agent onlyNew JVM, same companion. Loads the EDN immediately.

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.clj

This 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 -> to it 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).
  • R reloads the current EDN (the watcher also reloads on save). Overlay re-reads .metrics/ on the next load.
  • Esc on the class card closes it. Closing the main window exits the app.

Policy

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/.

Do not invent layers (components)

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:

  1. Set :src and :prefix to the project's source root and ns prefix (src and foo for foo.bar.baz).
  2. Set :hierarchical true (or omit :packages and :diagrams).
  3. List top-level segments in :order — the first dotted part after the prefix, in the order you want the boxes. Do not invent names.
  4. List real libraries in :foreign if they should appear as ovals.
  5. Optionally override a require with :edge-kinds {[:from :to] :association} using the leaf ids (clojure-language.source-clojure, not clojure-language).
  6. Set :levels so the generator can mark dependency-rule violations (see Dependency rule).
  7. Optionally set :proposal to name design components that are not namespaces (see Proposed components).
  8. 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}}
KeyRole
:prefixStrip this from each ns; remaining dots are the tree
:hierarchicalNamespace tree (default when :packages is omitted)
:orderOrder of existing top-level ns segments, not new component names
:levelsGroups of those segments, inner (higher-level) first. Same group = same rank
:proposalsNamed groupings of real segments; not instantiated in source. Inspector list; P returns to the ns tree
:edge-kindsOverride parser kind for [from to] (usually :association)
:omit-edgesDrop [from to]
:langWhich LanguageGraph to use (default :clojure)
:foreignExternal libs as ovals. A listed prefix collapses quil.core to quil.

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 :order if you care about box order.
  • Nested nses appear as contents of the parent component.
  • “This require is really an association”: one :edge-kinds entry.
  • Show a library like quil as an oval: add it to :foreign.
  • Do not add :packages to fake Clean Architecture components. Use :proposals to view a grouping that is not in the code.
  • Preserve :proposals when 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.

Dependency rule

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:

  1. Take the first dotted segment of each end (engine.layoutengine).
  2. Look up that segment in :levels. Rank is the group's index; smaller is inner / higher-level.
  3. If both ends have a rank and from-rank < to-rank, the edge is :violating true. Same rank is allowed. :implements and :association are never violating. Foreign / unranked ends are not compared.
  4. Collapsed component arrows keep the flag if any bundled leaf dependency was violating. Remapping a pair to :association clears 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.

Proposed components

: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.

Companion mailbox

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.

FileDirection
.uml-viewer/to-viewer.ednGrok → viewer
.uml-viewer/to-agent.ednviewer → Grok

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

:opMeaning
:displayViewer loads :path (relative to the project root)
:regenGrok rewrites hierarchical policy, regenerates IR, then :display
:quit-for-restartViewer exits the JVM without killing Grok. The associated agent then runs clj -M:uml-viewer-restart.

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.

Language graphs

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:

methodrole
scanfrom a root directory and {:prefix …}, return {:classes :edges}

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.

IR

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, 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, :private on 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). The class row shows average CRAP with a μ suffix and omits CC. Killed and survived use the same mutation-grade colors as the M dot.

Edge :kind values:

kindlinehead
:inheritancesolid greyempty triangle
:implementssolid greyempty triangle
:associationsolid greyopen arrow
:dependencysolid greyopen arrow
:aggregationsolid greyempty diamond
:compositionsolid greyfilled diamond

Layout follows Mermaid's three stages:

  1. Size each class from its text (padding 12).
  2. Place packages in document order; classes Sugiyama-ranked inside a package; ~40px spacing.
  3. 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 curveBasis cubics. All arrows are solid grey. Paths that pass the target and reverse are rejected.

Source extractors

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:

methodrole
locatepath to the file that should contain the member
extractslice that member out of the file text
titlewindow title

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.

Contributors

unclebob

30 commits

unclebob/uml-viewer

Live Quil UML class-diagram viewer driven by EDN

Clojure

106

30 commits

updated Sep 18, 2026

See the code

See what people are saying (1)

SourceMessageScoreDate

Uncle Bob Martin's UML Tool to Manage Grok AI Agents

1

Sep 18, 2026

README

UML viewer

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.

Run

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 --help

A 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:

AliasWhoWhat
:uml-vieweranyoneFresh window. Starts the companion. Waits for :display.
:uml-viewer-restartassociated agent onlyNew JVM, same companion. Loads the EDN immediately.

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.clj

This 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 -> to it 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).
  • R reloads the current EDN (the watcher also reloads on save). Overlay re-reads .metrics/ on the next load.
  • Esc on the class card closes it. Closing the main window exits the app.

Policy

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/.

Do not invent layers (components)

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:

  1. Set :src and :prefix to the project's source root and ns prefix (src and foo for foo.bar.baz).
  2. Set :hierarchical true (or omit :packages and :diagrams).
  3. List top-level segments in :order — the first dotted part after the prefix, in the order you want the boxes. Do not invent names.
  4. List real libraries in :foreign if they should appear as ovals.
  5. Optionally override a require with :edge-kinds {[:from :to] :association} using the leaf ids (clojure-language.source-clojure, not clojure-language).
  6. Set :levels so the generator can mark dependency-rule violations (see Dependency rule).
  7. Optionally set :proposal to name design components that are not namespaces (see Proposed components).
  8. 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}}
KeyRole
:prefixStrip this from each ns; remaining dots are the tree
:hierarchicalNamespace tree (default when :packages is omitted)
:orderOrder of existing top-level ns segments, not new component names
:levelsGroups of those segments, inner (higher-level) first. Same group = same rank
:proposalsNamed groupings of real segments; not instantiated in source. Inspector list; P returns to the ns tree
:edge-kindsOverride parser kind for [from to] (usually :association)
:omit-edgesDrop [from to]
:langWhich LanguageGraph to use (default :clojure)
:foreignExternal libs as ovals. A listed prefix collapses quil.core to quil.

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 :order if you care about box order.
  • Nested nses appear as contents of the parent component.
  • “This require is really an association”: one :edge-kinds entry.
  • Show a library like quil as an oval: add it to :foreign.
  • Do not add :packages to fake Clean Architecture components. Use :proposals to view a grouping that is not in the code.
  • Preserve :proposals when 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.

Dependency rule

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:

  1. Take the first dotted segment of each end (engine.layoutengine).
  2. Look up that segment in :levels. Rank is the group's index; smaller is inner / higher-level.
  3. If both ends have a rank and from-rank < to-rank, the edge is :violating true. Same rank is allowed. :implements and :association are never violating. Foreign / unranked ends are not compared.
  4. Collapsed component arrows keep the flag if any bundled leaf dependency was violating. Remapping a pair to :association clears 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.

Proposed components

: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.

Companion mailbox

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.

FileDirection
.uml-viewer/to-viewer.ednGrok → viewer
.uml-viewer/to-agent.ednviewer → Grok

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

:opMeaning
:displayViewer loads :path (relative to the project root)
:regenGrok rewrites hierarchical policy, regenerates IR, then :display
:quit-for-restartViewer exits the JVM without killing Grok. The associated agent then runs clj -M:uml-viewer-restart.

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.

Language graphs

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:

methodrole
scanfrom a root directory and {:prefix …}, return {:classes :edges}

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.

IR

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, 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, :private on 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). The class row shows average CRAP with a μ suffix and omits CC. Killed and survived use the same mutation-grade colors as the M dot.

Edge :kind values:

kindlinehead
:inheritancesolid greyempty triangle
:implementssolid greyempty triangle
:associationsolid greyopen arrow
:dependencysolid greyopen arrow
:aggregationsolid greyempty diamond
:compositionsolid greyfilled diamond

Layout follows Mermaid's three stages:

  1. Size each class from its text (padding 12).
  2. Place packages in document order; classes Sugiyama-ranked inside a package; ~40px spacing.
  3. 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 curveBasis cubics. All arrows are solid grey. Paths that pass the target and reverse are rejected.

Source extractors

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:

methodrole
locatepath to the file that should contain the member
extractslice that member out of the file text
titlewindow title

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.

Contributors

unclebob

30 commits

Languages

Clojure

100.0%