Skip to content

Add CodeGraph: a Swift codebase relationship visualizer#27

Open
kyleve wants to merge 21 commits into
mainfrom
code-graph-visualizer
Open

Add CodeGraph: a Swift codebase relationship visualizer#27
kyleve wants to merge 21 commits into
mainfrom
code-graph-visualizer

Conversation

@kyleve

@kyleve kyleve commented Jun 19, 2026

Copy link
Copy Markdown
Owner

Posted by an AI agent on kve's behalf.

Summary

Adds CodeGraph, a self-contained tool under Tools/CodeGraph/ for visualizing how the repo's Swift types relate (inheritance, conformance, data flow, membership). It's a two-process design, because the data source (IndexStoreDB) is a macOS build artifact that doesn't belong in a sandboxed app:

  • code-graph-extract — a SwiftPM CLI that builds/locates the compiler index store, harvests every first-party / external / test symbol and relationship, and emits a graph.json. Has a --watch mode that re-extracts on index-store changes.
  • CodeGraph viewer — a standalone Mac Catalyst SwiftUI app (its own Tuist project) that loads graph.json (open panel, drag-and-drop, or launch path), hot-reloads it, runs a force-directed layout, and renders an interactive, filterable, draggable UML-style canvas with named saved views.
  • CodeGraphModel — a small dependency-free SPM package with the shared graph schema, so the viewer links it without dragging in IndexStoreDB's macOS-only C++ deps.

Nothing outside Tools/CodeGraph/ is touched.

Highlights

  • Index-store harvesting with compiler-synthesized symbols ($/_ projections, accessors, generic params) filtered out.
  • Force-directed layout actor (Fruchterman–Reingold with grid-accelerated repulsion, module clustering, deterministic seeding, pinned + warm-started nodes, cooperative cancellation).
  • Client-side filtering by origin / node kind / module / name, N-hop focus, and member expand/collapse.
  • Persisted pins, filters, and saved views (keyed per repo path).
  • Performance work for large graphs: debounced relayout, viewport culling, Equatable chips, and node-drag isolation (only the dragged chip + its incident edges repaint per frame).

Test plan

  • swift test for the extractor package (SymbolMapping logic) — green.
  • Catalyst unit tests (tuist test CodeGraphViewer, Mac Catalyst): LayoutEngine determinism/pinning/warm-seed/cancellation + GraphViewModel filtering/focus/expand/seeding/reconcile + settling-flag — 21 tests green.
  • ./swiftformat --lint clean on the tool.
  • Manual: extract a graph.json from this repo and exercise the viewer (load, filter, focus, drag-to-pin, save view, hot-reload). Worth a manual drag pass since gesture behavior isn't unit-tested.

🤖 Generated with Cursor

Made with Cursor

kyleve and others added 21 commits June 16, 2026 23:03
Scaffolds Tools/CodeGraph as a standalone SwiftPM package for the code
relationship visualizer. CodeGraphModel defines the Codable snapshot
schema (CodeGraph/Node/Edge/Module with NodeKind/EdgeKind/Origin), a
shared ISO-8601 JSON coding config, and a bundled hand-written
sample-graph.json so the viewer can be built before the extractor
exists. Node decodes tolerantly (defaults isGeneric) for hand-written
and older snapshots. Tests round-trip the fixture and assert referential
integrity.

Closes plan to-do: scaffold-model.

Co-authored-by: Cursor <cursoragent@cursor.com>
Adds the executable target (ArgumentParser) to the CodeGraph package
with the indexstore-db + swift-argument-parser dependencies. The CLI
resolves libIndexStore from the active toolchain via xcrun, resolves the
index store either from --index-store-path or a dedicated reproducible
build (tuist generate --no-open + xcodebuild build-for-testing into
.codegraph/DerivedData with COMPILER_INDEX_STORE_ENABLE=YES), and opens
IndexStoreDB against it. Harvesting the graph lands next.

Bumps the package macOS minimum to 14 (required by IndexStoreDB).

Closes plan to-do: extractor-index.

Co-authored-by: Cursor <cursoragent@cursor.com>
Adds GraphBuilder, which walks the repo's first-party Swift files in the
opened index store and assembles a CodeGraph:

- definitions become typed nodes (origin first-party/test/external,
  parent resolved from the childOf relation, generics flagged);
- structural edges come from index relations: membership (childOf),
  overrides (overrideOf), and inheritance/conformance, which Swift
  records on the base type's reference occurrence (occurrence = base,
  baseOf relation -> derived), classifying class bases as inheritance
  and protocol/raw-value bases as conformance;
- data-flow edges come from reference occurrences resolved through their
  containedBy/calledBy container: property types, parameter/return
  types, construction (init calls), and generic constraints;
- external symbols the repo touches are synthesized as leaf nodes, and
  module nodes plus moduleDependency edges are derived from observed
  cross-module references.

The result is written atomically to .codegraph/graph.json. Validated
against the tool's own package index: 282 nodes / 599 edges, correct
conformance direction, zero dangling edges or parents.

Closes plan to-do: extractor-harvest.

Co-authored-by: Cursor <cursoragent@cursor.com>
Real-repo extraction surfaced ~1,165 noise nodes: macro expansions and
$-projected values (e.g. @test / #Preview content, which Swift mangles
with a leading $ that can't begin a user identifier) and @observable /
property-wrapper backing storage (_foo, _$observationRegistrar) whose
user-facing foo already appears. Skip names that are empty or begin with
$ or _ when mapping symbols to nodes.

Validated against the Where workspace index: 2,865 nodes / 7,908 edges,
correct inheritance/conformance directions and module dependencies, zero
dangling edges or parents.

Co-authored-by: Cursor <cursoragent@cursor.com>
DataStoreWatcher monitors the store's units directory with a
DispatchSource vnode source and fires a debounced callback when a build
writes new index data. In --watch mode the CLI does the initial
extraction, then refreshes the IndexStoreDB (pollForUnitChangesAndWait)
and rewrites graph.json on each change, handing the process to
dispatchMain so the GCD source keeps running until interrupted.

Verified against the Where store: touching the units directory triggers
a debounced re-harvest and atomic rewrite.

Closes plan to-do: extractor-watch.

Co-authored-by: Cursor <cursoragent@cursor.com>
Groundwork (no behavior change): move the shared graph schema into
Tools/CodeGraph/CodeGraphModel as a standalone SwiftPM package with no
external dependencies, and have the extractor package depend on it by
path. This keeps indexstore-db (macOS-only) out of the model's
dependency graph so the upcoming Mac Catalyst viewer can consume
CodeGraphModel without resolving it.

Extractor builds and re-extracts identically (2,865 nodes / 7,908
edges); model tests pass.

Co-authored-by: Cursor <cursoragent@cursor.com>
Standalone Tuist project (Tools/CodeGraph/Viewer) for a sandboxed Mac
Catalyst app depending only on the dependency-free CodeGraphModel
package. GraphStore opens a user-selected graph.json via the file
importer, persists access with an app-scoped security-scoped bookmark
(reopened on launch), decodes it with the shared model, and a
DispatchSource FileWatcher re-reads it on every change (re-arming across
the extractor's atomic replaces). A placeholder summary view stands in
for the interactive canvas, which lands next.

Builds for Mac Catalyst (BUILD SUCCEEDED).

Closes plan to-do: viewer-scaffold.

Co-authored-by: Cursor <cursoragent@cursor.com>
LayoutEngine settles nodes with Fruchterman–Reingold forces off the main
actor: grid-bucketed repulsion (keeps the full graph ~O(n) per step),
edge spring attraction, and a gentle pull toward each module's centroid
for clustering. Seeding is a deterministic phyllotaxis spiral so a given
graph always settles identically, dragged nodes can be pinned (held
fixed while still exerting forces), and iteration count adapts to graph
size with temperature cooling.

Validated standalone: deterministic and finite positions, pins held,
sensible spread; ~2,900 nodes / 8,000 edges settle in ~0.5s.

Closes plan to-do: viewer-layout.

Co-authored-by: Cursor <cursoragent@cursor.com>
Replace the placeholder summary with a real graph view driven by
GraphViewModel: a Canvas draws edges (colored and dashed per kind, with
direction arrowheads and selection emphasis) and nodes render as
positioned chips (glyph + name, kind color, pin/generic/expand
affordances). Adds pan, pinch-zoom, fit-to-content, tap-to-select with a
neighbor-dimming focus, drag-to-reposition-and-pin, context menus, and
expand/collapse of a type's members. An inspector panel lists the
selected node's declaration and its incoming/outgoing relationships,
each row jumping to the other end. Layout reruns off the main actor on
any visibility change; a sensible default edge-kind set keeps the first
view readable.

Builds for Mac Catalyst (BUILD SUCCEEDED).

Closes plan to-do: viewer-render.

Co-authored-by: Cursor <cursoragent@cursor.com>
A filter popover drives the visible set: name search, scope toggles
(tests / external / module nodes), per-node-kind and per-edge-kind
inclusion, and per-module hide/show, plus a Reset. Selecting a node and
choosing Focus (context menu, inspector, or toolbar) restricts the
canvas to that node's N-hop neighborhood over the included edges, with
an adjustable depth. The view model gains the filter state, a BFS focus
neighborhood, and toggle/reset helpers; visibility now also respects
node kind and module.

Builds for Mac Catalyst (BUILD SUCCEEDED).

Closes plan to-do: viewer-filter.

Co-authored-by: Cursor <cursoragent@cursor.com>
ViewerState captures the arrangement (pinned positions keyed by USR,
filters, expand/focus) and is stored per repo path in the app container.
On load — including hot reloads after a re-extract — it's restored and
reconciled against the new graph (pins/expanded/focus for vanished
symbols are dropped). Changes autosave on a short debounce. A Views menu
saves the current arrangement under a name and applies or deletes saved
views.

Builds for Mac Catalyst with no warnings.

Closes plan to-do: viewer-persist.

Co-authored-by: Cursor <cursoragent@cursor.com>
run.sh wires the loop end to end: build the extractor, extract the graph
(tuist generate + build-for-testing + harvest), then build and open the
Catalyst viewer. It refuses --watch (which blocks) and points at the
standalone watch invocation instead.

README documents the two-process architecture, the dependency-free model
split, the extractor flags, the viewer's features, and the node/edge
data model. .gitignore drops .codegraph/ (dedicated build + graph.json)
and generated Xcode artifacts.

Closes plan to-do: glue-docs.

Co-authored-by: Cursor <cursoragent@cursor.com>
graph.json lives in the hidden .codegraph/ directory, which is awkward to
reach through an open panel. Add two ways in that route through the
existing GraphStore.open (sandbox grant + bookmark + hot-reload watcher):

- onOpenURL plus a public.json document type (rank Alternate), so
  `open -a CodeGraph graph.json` hands the file straight to the app. run.sh
  now launches the viewer that way instead of expecting a manual pick.
- A drag-and-drop target on the window (with a hover highlight) and an
  empty-state hint.

The open panel still works as a fallback. Builds for Mac Catalyst with no
warnings.

Co-authored-by: Cursor <cursoragent@cursor.com>
Check in the original "Swift Code Graph Visualizer" plan this tool was
built from, for provenance. Reproduced verbatim from the planning step;
it predates implementation so some details drifted (e.g. it lists 8 edge
kinds; the model ended up with 11).

Co-authored-by: Cursor <cursoragent@cursor.com>
Four targeted performance fixes in the viewer's hot paths:

- Debounce + warm-start the layout. Filter/search changes now recompute
  the visible set immediately but coalesce the (previously per-keystroke)
  relayout, and the engine warm-starts from current positions — keeping
  existing nodes put and dropping brand-new ones beside their neighbors
  instead of reshuffling the whole graph from the seed spiral each time.
- Cooperative layout cancellation. The force simulation checks
  Task.isCancelled each iteration, so a superseded relayout bails instead
  of running to completion on the serialized actor.
- Equatable node chips (.equatable()) so a drag or settle animation only
  re-renders the chips that moved, not every visible node's body.
- onChange(of: graph.generatedAt) instead of the whole CodeGraph, dropping
  a full nodes+edges equality check on every view update.

Cold-layout seeding is unchanged (RNG draw order preserved), so the first
layout stays deterministic. Builds for Mac Catalyst with no warnings.

Co-authored-by: Cursor <cursoragent@cursor.com>
- Viewport culling: only nodes whose positions land inside the visible
  canvas rect (grown by a one-viewport margin) are instantiated as chips,
  so zooming in no longer keeps thousands of off-screen interactive views
  (each with gestures + a context menu) alive. Culling keys off the
  committed scale/offset, so an in-flight pan/zoom just transforms the
  existing layer rather than re-culling every frame; the margin covers a
  full viewport of panning before any gutter shows, and at fit-zoom the
  rect spans the whole graph so nothing is dropped.
- Drop the per-chip ambient shadow; only the selected chip casts one. A
  shadow is a blur/compositing pass per chip, which piled up across many
  visible nodes — the border already separates chips from the edges and
  background.

Builds for Mac Catalyst with no warnings.

Co-authored-by: Cursor <cursoragent@cursor.com>
Cover the pure pieces flagged as untested (review item #8):

- SymbolMapping: index-symbol-kind/subkind to NodeKind mapping,
  synthesized-name filtering, isType, and path-based origin. Added a
  test target to the extractor package (@testable import of the CLI).
- LayoutEngine: same input settles identically, pinned nodes stay put,
  warm-start preserves seeded positions and anchors new nodes near
  neighbors, and a cancelled run bails empty.
- GraphViewModel: origin/kind/edge/module/name filters, expand-to-reveal
  members, N-hop focus neighborhood, and persistence reconciliation that
  drops references to symbols that no longer exist.

To make the view-model tests deterministic, ViewerPersistence and
GraphViewModel now take an injectable UserDefaults/persistence so each
test runs against an isolated suite instead of the app container.

Wire a CodeGraphViewerTests unit-test bundle + scheme into the viewer
project. Pin the Catalyst MACOSX_DEPLOYMENT_TARGET to 26.0 so the host
app and tests launch on a macOS 26 machine (iOS 26 otherwise derives a
macOS 27 Catalyst deployment).

Co-authored-by: Cursor <cursoragent@cursor.com>
Reading stdout to EOF before touching stderr deadlocks if a child fills
the ~64KB stderr pipe buffer first: the child blocks writing stderr while
we block reading stdout. Drain stderr on a background work item so both
pipes empty concurrently. (No injection risk — Process takes an args
array, not a shell string.)

Co-authored-by: Cursor <cursoragent@cursor.com>
Two small viewer-state fixes:

- A cancelled *final* layout task left isLayingOut stuck true (the
  settling spinner never cleared). Tag each layout with a generation so
  only the newest task applies its result and clears the flag — whether
  it finished or was cancelled — while superseded tasks bow out.

- Newly-visible nodes had no entry in positions until the debounced
  relayout finished, so they flashed invisible after a filter change or
  member expand (the canvas only draws nodes it has a position for). Seed
  a provisional position on reveal — the centroid of already-placed
  neighbors, falling back to the module centroid — so they paint at once.
  Skipped on the cold first layout, where the settle places everything.

Co-authored-by: Cursor <cursoragent@cursor.com>
Dragging used to rewrite the model's positions dict on every gesture
tick. Because @observable tracks at property granularity, each write
re-ran the edge Canvas (O(edges)) and rebuilt chip(for:) for every
visible node (O(nodes)) — every frame, though one node moved.

Split the canvas into independently-observing layers and drive the live
drag from view-local state instead of the model:

- DraggableChip owns the live offset in @GestureState, so moving it
  re-renders only that chip; the committed position lands on the model on
  drag end.
- A view-local CanvasDragState carries the dragged id + live point.
- StaticEdgeLayer draws every edge except the dragged node's incident
  ones and reads only the id, so it stays frozen for the whole drag.
- DraggedEdgeLayer redraws just the incident edges from the live point,
  with neighbor endpoints cached at drag start (O(incident), not O(all)).
- NodeLayer (and its O(N) culling) never reads the drag state, so it
  isn't re-evaluated mid-drag.

The user still sees live edge-follow; we just repaint a few views per
frame instead of the whole graph. The unused model.drag(_:to:) goes away.

Co-authored-by: Cursor <cursoragent@cursor.com>
Members no longer float as their own nodes wired up with .member arrows;
each type now draws as a UML-style box with a header and a compartment of
member rows (cap 6 + "+N more"/Show less), data-first ordering, and the
property type recovered from propertyType edges.

To support this the view model:
- never surfaces members as standalone visible nodes
- projects every canvas edge endpoint to its owning type, dropping .member
  edges and any edge that collapses onto one type, de-duped per kind
- precomputes the flattened member rows once (the graph is fixed)

Drops .member/.override from the default and filterable edge kinds (the
former is now visual containment), and updates the model tests for the
new members-as-rows semantics.

Co-authored-by: Cursor <cursoragent@cursor.com>
@kyleve kyleve force-pushed the code-graph-visualizer branch from 1ba3aa5 to a99ce86 Compare June 19, 2026 22:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant