Add CodeGraph: a Swift codebase relationship visualizer#27
Open
kyleve wants to merge 21 commits into
Open
Conversation
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>
1ba3aa5 to
a99ce86
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 agraph.json. Has a--watchmode that re-extracts on index-store changes.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 inIndexStoreDB's macOS-only C++ deps.Nothing outside
Tools/CodeGraph/is touched.Highlights
$/_projections, accessors, generic params) filtered out.Equatablechips, and node-drag isolation (only the dragged chip + its incident edges repaint per frame).Test plan
swift testfor the extractor package (SymbolMappinglogic) — green.tuist test CodeGraphViewer, Mac Catalyst):LayoutEnginedeterminism/pinning/warm-seed/cancellation +GraphViewModelfiltering/focus/expand/seeding/reconcile + settling-flag — 21 tests green../swiftformat --lintclean on the tool.graph.jsonfrom 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