bat-cli 0.26.10

Blockchain Auditor Toolkit (BAT)
# How bat-cli deploys call-graph diagrams to Miro

This document captures the DESIGN DECISIONS behind `deploy` — how a Solidity
function's call graph becomes a readable Miro diagram — so they aren't lost. It is
the "why", not a line-by-line of the code; the code lives in
`src/batbelt/evm/miro/auto_deploy.rs` and `src/batbelt/miro/layout.rs`.

---

## 1. The hard constraint that shapes everything

**Miro auto-routes connectors.** You can set the two endpoint anchors of a
connector, but NOT its waypoints — Miro draws its own orthogonal elbow between the
endpoints. So the layout engine can compute perfect corridors, but Miro will still
draw a shared node's arrow straight across whatever screenshots sit between the
caller and the callee.

Consequence: **crossings cannot be fixed by the layout alone.** The only way to
remove the arrow that crosses is to make the callee LOCAL to its caller (a copy or
a nearby card), so the arrow is short. Everything below follows from this.

A second hard rule from the auditor: **never crop, fold, or hide source code** —
every screenshot shows the complete function. So "make it fit" is never "show
less code"; it's "split into more frames" or "repeat a screenshot".

---

## 2. The pipeline (order matters)

`deploy_one` runs these in order. The ORDER is a decision, not an accident:

1. **Build the graph** (`build_graph`) — DFS from the entry point, one node per
   function, edges per call site. No duplication here.
2. **Render** each screenshot (`render_and_measure`) — silicon renders one PNG per
   DISTINCT function; see §6.
3. **Recycle / link** — a callee that already has a live frame becomes a link card
   (skipped in `--inline-all` and `--redeploy`).
4. **Framing** (the cut loop) — split the graph into readable sub-frames; §4.
5. **Localize** (`duplicate_crossing_shared`) — copy the small helpers that STILL
   cross, AFTER framing; §5. THIS ORDER IS THE KEY INSIGHT.
6. **Layout** (`layout_graph`, Sugiyama) — position everything; §3.
7. **Upload** — create the frame, images, connectors; record what was made.

---

## 3. Layout: Sugiyama (layered graph drawing)

`layout.rs` implements the Sugiyama framework (the "Japanese algorithm"):

- **Layering** by longest path (`assign_layers`) → left-to-right columns, no
  backward arrows.
- **Dummy/bend nodes** (`insert_bend_points`) reserve a corridor for every
  layer-skipping edge, so long edges route in the gaps between columns.
- **Crossing reduction** by the barycenter/median heuristic with sweeps
  (`order_layers` + `count_crossings`), keeping the fewest-crossings ordering.

`count_crossings` gives the EXACT number of crossings for a layout — this is the
ground truth we measure against (not a distance proxy). A pure tree lays out with
ZERO crossings; crossings ⟹ shared nodes (fan-in ≥ 2) reached from distant callers.

**Every layer is TOP-aligned, not centred.** A caller (a long entry point) sits at
the top-left; centring its callees would push them to the middle, so arrows from the
caller's top AND bottom call sites both converge inward and cross. Top-aligning keeps
the fan-out monotonic — the cascade flows down-and-right from the top — and keeps deep
chains from drifting far down (see `layout.rs`, the `y_cursor` init).

---

## 4. Framing: partition a big graph into readable frames

Constants (`auto_deploy.rs`): `FRAME_TARGET = 15`, `FRAME_MAX = 20`,
`FRAME_MIN = 6`, `MAX_CUTS_PER_FRAME = 10`, plus a depth penalty
(`DEPTH_FREE_LAYERS = 5`, `DEPTH_PENALTY = 0.15`).

**Decisions:**

- **Aim AT a size, don't just cap.** A frame is aimed at ~15 screenshots (readable
  at a normal zoom, ~30 connectors). Above `FRAME_MAX` (20, depth-adjusted via
  `effective_size`) it is split; a piece that is itself still too big becomes its
  own frame and is split again → a shallow HIERARCHY of readable frames, not one
  wall or a scatter of husks.
- **Cut for BALANCE, not "biggest".** `best_cut` scores each candidate on how close
  its subtree size lands to the per-piece budget, minus the cross-frame edges the
  cut severs, plus small bonuses for reuse and an already-existing target frame.
  Cutting the biggest branch first left lopsided halves; nearness-to-target fixes it.
- **`cut_node` lifts a SHARED node out whole.** In a densely-shared DAG, cutting one
  edge frees nothing (the subtree still hangs off another caller). `cut_node`
  replaces EVERY in-edge to a node with its own link card, so the whole subtree
  leaves the frame — the only way to partition a diamond.
- **Husk guard, both directions.** Neither the new piece nor the leftover frame may
  fall below `FRAME_MIN` (6). This forbids "pass-through" frames (one screenshot
  pointing at another frame).
- **Depth counts.** `effective_size` inflates the count for frames deeper than
  `DEPTH_FREE_LAYERS`, because horizontal px is the scarce resource; a deep-narrow
  frame is split sooner than a shallow-wide one of the same count.
- **`MAX_CUTS_PER_FRAME` was silently capped at 5 by a leftover `MAX_CUT_PASSES`.**
  Fixed to 10 — a 182-node graph could otherwise shed only 5 branches and ship a
  110-screenshot residual.

---

## 5. Localize small crossers — AFTER framing (the key insight)

`duplicate_crossing_shared` (constants `CROSS_LAYERS = 2`, `MAX_CLOSURE = 3`):

- **What it does:** lay the graph out, and for a SHARED node whose subtree is small
  (`MAX_CLOSURE`) and whose caller sits ≥ `CROSS_LAYERS` columns back (its arrow
  would skip a column and cross), give that far caller a LOCAL COPY of the node.
  Callers in the adjacent column keep sharing the one node (no needless repeat). The
  nearest caller always stays on the original so it is never orphaned.
- **Why AFTER framing, not before:** running this before framing was whack-a-mole —
  the shared-node count stayed constant because copies re-called the same deep
  helpers, which then gained fan-in. Framing first removes the deep "floor" (the
  big shared subtrees go to their own frames), so the leftover frame is small and
  copying its remaining small leaves is cheap and actually converges.
- **Copies inherit the render** (they run after §6), so no re-render.

**Decision on the metric:** distance (≥2 columns back) is only the heuristic for
WHICH node to copy; the CERTIFICATE that a crossing is gone is `count_crossings`
from the re-layout — "close" is not a guarantee.

Result on the entry point this was developed against: the main frame went from a
178-screenshot mesh to a readable ~37 (framing), with the residual small crossers
copied local.

---

## 6. Render dedup + scaling

Decisions in `render_and_measure` / `make_node`:

- **Render each DISTINCT function once per RUN**, not once per node. The PNG name is
  `fn_<file>_<start>_<end>.png` (no owner prefix), so every frame in a run (a whole
  `--redeploy` cluster) SHARES it; a function that appears in several frames renders
  once. Cleanup is deferred to the end of the run so the shared files survive.
- **One reference font + scale, not a re-render per depth.** Every screenshot renders
  at `REFERENCE_FONT` (32, the depth-0 size); a deeper node reuses that image shrunk
  via `GraphNode.scale` (Miro image `width` only — geometry keeps aspect ratio).
  Always scale DOWN, so text stays crisp. Line fractions are scale-invariant, so
  only `board_width`/`board_height` and the upload width multiply by `scale`.
- **Note:** render is NOT the deploy bottleneck — the API calls (image uploads +
  many connectors, no bulk-create) dominate. Dedup is a modest win.

---

## 7. Overloaded functions (resolved by arity)

A contract with several same-named functions (Solidity overloads, e.g. a public
`quote(...)` forwarding to an internal `quote(curve, ...)`) used
to map EVERY call to the first definition — so a wrapper calling its sibling looked
like a self-call and was dropped, and the whole implementation subtree vanished.

Fix: call sites carry their argument count (`CallSite.arg_count` from the AST);
`find_function` picks the overload whose `params.len()` matches; each overload is a
distinct node (`overload_node_key` appends the definition line ONLY when the name is
overloaded, so non-overloaded graphs are byte-identical). The DFS re-reads the exact
overload by line (`find_function_at`).

---

## 8. `--redeploy`: fresh cluster in a clean zone, old URLs handed back

Deleting is slow on the Miro API: there is NO bulk-delete, and deleting a frame does
NOT cascade to its children (verified empirically — the child stayed alive). So
programmatic teardown is one-by-one and slow.

Decision: `--redeploy` does NOT delete. It:

- Draws the WHOLE cluster (entry point + every dependency frame) FRESH into a clean
  zone (the cached region is forgotten so the allocator re-scans below everything),
  reusing nothing already on the board — not this entry point's own old frames, not
  another deploy's frames — only frames created earlier in THIS run (within-cluster
  sharing, gated by `cluster_root == root && id ∉ stale_ids`).
- Stamps each new frame with `AutoDeployedFrame.cluster_root = <entry point>`, so a
  later `--redeploy` finds the whole previous cluster.
- At the end, prints the previous cluster's still-live frame URLs for ONE-CLICK
  manual deletion in Miro's web UI (which DOES delete a frame with its contents),
  and drops those stale records.

Resumability (skip already-complete frames) was considered and deferred — it needs a
per-frame "done" checkpoint; the auditor was fine re-scanning.

---

## 9. What did NOT work (so we don't retry it)

- **Duplicating shared subtrees to a tree** — EXPLODES. A deep diamond hits 240+
  nodes and STILL has ~26 shared nodes; the mesh regenerates because copies re-call
  the same deep helpers. Bounded duplication just stops half-de-shared.
- **Distance-gated duplication BEFORE framing** — whack-a-mole (see §5).
- **Satellites (hoist small helpers outside the frame)** — only partial; the reuse is
  distributed at ALL subtree sizes, not concentrated in a few leaves, so removing 9
  leaves barely dented the mesh. Superseded by framing + localize.
- **Making a shared node's screenshot smaller** — does nothing for crossings; the
  arrow still spans the same distance. Only a LOCAL copy shortens the arrow.
- **Upload-dedup via the first upload's Miro image URL** — the URL is authenticated
  and valid only ~60s, so it's non-deterministic and could block a mid-deploy;
  deprioritized.
- **`--preview` that also deployed** — was a footgun; `--preview` is now local-only
  (composes one PNG, never touches the board) and is the fast way to iterate.

---

## 10. The AI-first / CLI split (design philosophy)

The tool is meant to be driven by an AI assistant, but the auditor's rule is: **the
CLI must produce a good diagram with ZERO AI intervention** (the default is
deterministic), because the AI may lose context or ignore docs. AI involvement is a
small, optional refinement, never required.

- **CLI decides deterministically:** layout, framing, localization, cluster
  placement, frame naming/reuse. Same input → same output.
- **AI, if it wants, overrides only by EXCEPTION, by name** (the intended surface is
  a couple of flags like `--inline <fn>` / `--externalize <fn>` reading a table the
  CLI prints) — never tuning thresholds or re-inventing the layout, which is what
  brings inconsistency.
- **Self-documenting output** beats separate docs the AI won't load: print the
  override hint in stdout.

The classification signal for "utility to externalize" vs "business logic to keep
inline" is computable by the CLI: fan-in, subtree size (leaf vs deep), `is_pure` /
`is_library` from the AST, and real `count_crossings` — NOT auditor labels.

---

## 10b. What can be a root, and which contract a name means

**Naming something is a decision already made.** The picker lists the project's own functions,
minus constructors and `lib/`, because nobody wants to scroll past dependency plumbing. But
`--entry-point` reaches anything: a constructor, `fallback`/`receive`, a contract under `lib/`.
The motivating case was `FLAMMProxy.constructor` — an empty body whose entire behaviour is the
`BeaconProxy` it inherits — which could not be drawn at all.

**A constructor is drawn with the base constructors it runs.** `constructor(...) BeaconProxy(beacon,
data) {}` parses the base invocation as a modifier, which resolved to nothing, so the diagram
stopped at the root. Base constructors are edges now: anchored on the header token when invoked
there, on the signature when implicit, descending through bases that have no constructor of their
own. Only what construction RUNS is drawn — not every inherited function.

**A `lib/` root implies `--include-external`.** Everything such a function calls is dependency code
too; without the flag the frame silently collapses to one screenshot, and nothing says why.

**A name resolves through imports, the way `solc` resolves it.** Contract names are not unique:
`lib/` vendors several copies of the same library, and lookups used to take the first match in
the project — sometimes a copy the audited code never imports. Every lookup now resolves the name
through the using file's import graph and `remappings.txt`, nearest first
(`EvmBatMetadata::contract_in_scope`, `evm/parser/import_graph.rs`). For `--entry-point`,
several matches narrow deterministically — own entry points, own functions, `lib/`; then the copy
the audited code reaches — and the deploy prints `from <file>` when the name is shared.

**The CLI decides what the code decides; it stops for what the code cannot** (§10). An ambiguity
that imports settle is never a question. Two in-scope contracts both defining `poke`, or a library
nothing imports, stop with each candidate listed as `path/To.sol:Contract.function` — the form that
selects it. That is different from `bat-cli resolve`: an interface's implementation is bound at
deploy time, which no amount of reading imports can reveal.

---

## 11. Board hygiene (Miro side)

- **Board picker lists only boards you OWN** (`?owner=<user id>`), so a big org
  returns a handful instead of hundreds (it was hanging on 243), and you can't edit
  someone else's board by accident.
- **Boards bat-cli creates are PRIVATE** — `policy.sharingPolicy` with `access`,
  `teamAccess`, `organizationAccess` all `private`; audit diagrams are sensitive and
  a Business/Enterprise org shares new boards team-wide by default.

---

## 12. Where the knobs live (quick reference)

All in `src/batbelt/evm/miro/auto_deploy.rs` unless noted:

| Constant | Value | Meaning |
|---|---|---|
| `FRAME_TARGET` | 15 | screenshots a frame is aimed at |
| `FRAME_MAX` | 20 | effective size above which a frame is split |
| `FRAME_MIN` | 6 | husk floor (piece AND residual) |
| `MAX_CUTS_PER_FRAME` | 10 | max sub-frames carved from one frame |
| `DEPTH_FREE_LAYERS` / `DEPTH_PENALTY` | 5 / 0.15 | depth surcharge in `effective_size` |
| `CROSS_LAYERS` | 2 | caller columns-back that counts as a crossing |
| `MAX_CLOSURE` (localize) | 3 | only SMALL helpers are copied local |
| `REFERENCE_FONT` | 32 | the one font everything renders at; depth = scale |

Flags: `--inline-all` (one frame, measure size), `--preview <path>` (local PNG, no
board), `--redeploy` (fresh cluster + report old URLs), `--refresh-links` (surgical
swap of newly-framed callees), `--undeploy` (remove a frame + its items outright),
`--include-external` (draw `lib/` callees; implied by a `lib/` root).

`--entry-point` forms: `function`, `Contract.function`, `path/To.sol:Contract.function` (§10b).

## 13. Not implemented: call-ordered columns, connector lanes, no stagger

Three cosmetic defects survive in a wide fan-out, and all three force the reader to
rearrange the board by hand to follow the graph. Measured on a 33-node / 36-edge
frame (`--dry-run`), root fan-out of 18 edges:

- **Columns are not in call order.** 14 of 18 callees sat off their call-order slot,
  32 inverted `(call line, callee y)` pairs, plus the same defect in three deeper
  callers. Root cause is not the sweep count: `count_crossings` keys an edge by the
  integer *slot* of caller and callee, so two edges leaving the **same** caller have
  equal keys and can never count as crossing — the metric is blind to precisely this
  defect, and the call-order signal (`from_line_fraction`, used by the downward
  sweep) only ever survives as a tie-break. Compounding it, `sort_layer` sends
  keyless nodes to the END of the layer, which is what puts every leaf below every
  non-leaf.
- **Outgoing connectors collapse into one line.** Every group leaving a caller shares
  one vertical corridor x (the caller's right border, `+200` only when the call token
  itself reaches it), so with 18 edges the verticals fell inside 150px — 8.8px apart
  at 8px stroke.
- **Boxes' x forces crossings.** The per-column stagger (top box furthest right, ≤50px
  per rank / 300px total) exists only to un-stack Miro-chosen elbows; it spreads left
  edges by 300px and costs 700px of frame width, and the differing screenshot widths
  spread right edges by ~1500px, so arrows cross for no graph reason.

The design that fixes all three as one mechanism:

1. Make the crossing count honest — key each edge by `slot + from_line_fraction`, and
   let keyless nodes hold their slot instead of sinking. Then an out-of-call-order
   pair costs exactly 1, the same as any other crossing, so the layout trades it only
   when it genuinely saves crossings elsewhere.
2. **Lanes.** `layout.rs` fills the `routes` contract it already declares (nothing
   consumes it today) with real waypoints: each forward edge gets its own vertical
   lane in the gutter, `x_k = R_g + LANE_MARGIN + k · LANE_PITCH` (100 / 40 — five
   stroke widths apart, first turn clear of the red storage border), with the gutter
   widened to `max(gutter_x, 2·margin + (n-1)·pitch)`. Lanes are assigned by
   `(source y, target y)` so that non-inverted pairs provably never cross; inverted
   pairs cross once, which is topologically unavoidable. Upload draws a polyline of
   collinear marker-to-marker segments, so **Miro routes nothing** — the same
   invisible-marker trick already used for the call-token anchor, extended to the
   vertical leg. Layer-skipping edges reuse the gap already reserved by
   `insert_bend_points`, which today reserves a corridor and then drops it.
3. **Delete the stagger**, whose only job was separating elbows Miro no longer picks.

Simulated on that frame: inverted pairs 32 → 8 (the residue is one callee called from
two lines — one box cannot sit in two places), visual crossings 40 → 18, frame width
−160px net (the wider gutters cost less than the stagger did).

Why it is parked rather than shipped: it is a large change to the one pure, load-bearing
module for a purely aesthetic gain, and it carries real costs — ~1.6× API calls in the
connector phase (213 vs 135 here), a changed vertical order in deep layers so every
redeployed frame looks different from today's, an unverified minimum shape size for an
8px corner marker, and `refresh_links_surgical` keeping its one auto-routed connector
unless the registry also stores `callee_routes`. Nothing else moves: framing, localize,
pipeline order, the red/amber marks, `screenshot`, `undeploy`, `relink`, `resolve`.

A related idea, also parked: a `deploy_id` stamped inside the frame as content (not an
id), so a cut-and-pasted or re-deployed frame can be told apart from its twin. Children
already re-pair by content — every image carries `title` = node label, which is what
`rebuild_record` uses — so the gap is only the frame's own identity: today two frames
titled `auto: X` make `reanchor_frame` stop and ask for `--frame-url`, and the registry
holds one record per entry point. Keying the registry by a stamp instead would allow two
live frames of the same function, and would let a sweep repair inbound link cards, whose
`<a href=…moveToWidget=<frame_id>>` still points at the id the paste invalidated.