Capture LLVM bitcode from any build and query it through rllvm-query's MCP server.


Extract whole-program LLVM bitcode from any build.
Point your build system at rllvm's compiler wrappers, build normally, then pull a single .bc for the whole program back out of the finished binary.
brew install h1994st/tap/rllvm llvm # macOS or Linux with Homebrew
# Or: cargo install rllvm
# LLVM on Ubuntu/Debian: sudo apt install llvm llvm-dev clang libclang-dev
rllvm-cc hello.c -o hello
rllvm-get-bc hello # writes hello.bc
rllvm-info hello.bc # target, function, block, instruction counts
On first run, tool paths are detected from llvm-config and written to ~/.rllvm/config.toml.
In Claude Code, the rllvm plugin covers the whole workflow, from installing rllvm to reading an answer:
/plugin marketplace add h1994st/rllvm
/plugin install rllvm@rllvm
| Part | What it does |
|---|---|
setup skill | Installs rllvm and rllvm-query with your approval, picks an LLVM every reader of the bitcode accepts, configures, and troubleshoots |
capture skill | Chooses how to capture your build — C, C++, Objective-C, Rust, mixed, a compilation database, cross, WebAssembly, eBPF — and what to extract |
query skill | Picks the query that answers your question and reports what the answer does not cover |
complete-callgraph skill, /rllvm:complete-callgraph | Resolves unresolved indirect calls into a labeled call-graph overlay, never reported as proven |
Overlay view, /overlay-view | A read-only pane showing each unresolved field, its candidates, and the overlay's edges with confidence and verification, unsaved ones included, for the catalog that last answered |
| MCP server | rllvm-query mcp, configured automatically |
| Cache warning | A PostToolUse hook notes once per session when the facts cache passes query_cache_warn_mb; needs jq |
Builds run as ordinary shell commands you approve, never inside the server. See the plugin example.
rllvm follows the CC/CXX → build → extract workflow that wllvm and gllvm established. rllvm-cc and rllvm-cxx stand in for wllvm/gclang, rllvm-get-bc for extract-bc/get-bc.
Your build commands do not change. What you can capture, and what you can do with it afterwards, does.
| 🌟 rllvm | wllvm / gllvm | |
|---|---|---|
| 🦀 Languages | C, C++, Objective-C, Rust | C, C++, Fortran |
| 🔍 Analysis | Source-level queries, MCP server | ❌ |
| 🤖 Agents | Claude Code plugin: setup, capture, queries | ❌ |
| 📋 No wrapper build | Import compile_commands.json | ❌ |
| 🎯 Targets | Native, cross, WebAssembly, eBPF | Native; cross with a target objcopy |
| 🔗 LTO | Three modes, dispatched on object content | -flto unlikely to survive extraction |
| ♻️ Rebuilds | Cache validated against inputs | No bitcode cache |
| 📦 Moving a build tree | Paths relative to a root | Absolute bitcode paths |
| ⚙️ Setup | Detected from llvm-config | Environment variables |
The wrappers run Clang normally and also emit bitcode. Each object gets a custom section recording its bitcode path. The linker concatenates those sections, so the finished binary carries a list of every module that went into it; extraction reads the paths back out and merges the modules.
source.c → rllvm-cc → object + bitcode
↓
linker → executable
↓
rllvm-get-bc → whole-program.bc
Rust captures bitcode per crate: linked crates carry paths through a marker object, library crates in archive members.
This is why capture only covers what you build through the wrappers. Linking a prebuilt native library contributes no bitcode, so build every dependency you need for a whole-program view.
rllvm-cc wraps clang and rllvm-cxx wraps clang++. Objective-C (.m) and Objective-C++ (.mm) sources go through the same two wrappers.
rllvm-cc hello.m -o hello -framework Foundation
rllvm-get-bc hello # writes hello.bc
# Autotools
CC=rllvm-cc CXX=rllvm-cxx ./configure && make
rllvm-get-bc path/to/program
# CMake
CC=rllvm-cc CXX=rllvm-cxx cmake -S . -B build && cmake --build build
rllvm-get-bc build/my_program
Add OBJC=rllvm-cc OBJCXX=rllvm-cxx for Objective-C projects. CMake also accepts -DCMAKE_TOOLCHAIN_FILE=path/to/rllvm/cmake/rllvm-toolchain.cmake, which sets all four compilers; see the CMake and Objective-C examples.
RUSTC_WRAPPER=rllvm-rustc cargo build
rllvm-get-bc target/debug/my_program
Wrapped dependency crates contribute modules when their archive members reach the link. You can also extract an .rlib directly, or invoke the wrapper without Cargo:
rllvm-get-bc 'target/debug/deps/libmylib-<hash>.rlib'
rllvm-rustc main.rs -o app && rllvm-get-bc app
cargo check and procedural-macro crates pass through without capture, and prebuilt dependencies — including the standard library — are not rebuilt. Neither is the allocator shim rustc adds to a staticlib (__rust_alloc and its siblings); rllvm-info lists such members as carrying no bitcode. Your LLVM readers must be compatible with the version rustc -vV reports. Use RLLVM_LOG_LEVEL=3 for diagnostics under Cargo.
See the Rust example.
Every compiler flag reaches the real compiler, including -c, -v, --help and --version. Wrapper options are long-only, prefixed --rllvm-, and go first:
--rllvm-compiler <PATH> Override clang/clang++ (C/C++ wrappers only)
--rllvm-verbose[=LEVEL] Bare flag is 1; 3 logs subcommands, 4 enables trace
--rllvm-help Print wrapper help
--rllvm-version Print wrapper version
rllvm-cc --rllvm-verbose=3 -pthread -c hello.c -o hello.o
rllvm-cc @compile.rsp
Response files follow Clang's GNU UTF-8 syntax, including quoting and nested references. Large generated commands use them automatically.
See the wrapper options example.
rllvm-compdb compiles selected entries from an existing compile_commands.json, so you can capture bitcode without rebuilding through the wrappers:
rllvm-compdb list build/ > compilations.json
rllvm-compdb generate build/ --source src/example.c --output-dir analysis/example
list reports entry and configuration IDs without compiling. generate takes all entries by default; repeat --source or --entry to narrow, and combine them to intersect. Only direct clang/clang++ drivers are supported, and the output directory must be new. Modules are read with the llvm-dis in the configured llvm_bindir, so generate refuses a compiler from a newer LLVM major before compiling anything; Apple clang's version names no LLVM major, so it only draws a warning.
These modules describe the current source tree, not membership in a real link. Use wrapper capture when participation in the actual build matters.
See the compilation database example.
Off by default. RLLVM_CACHE=1 enables it, storing in ~/.rllvm/cache:
RLLVM_CACHE=1 cmake --build build
Native compilation still runs — a hit only skips the extra bitcode compilation, after checking preprocessed inputs, dependencies, compiler, command, directory and environment. Keep it off when side inputs that preprocessing cannot see, such as optimization profiles, change between builds.
The platform's default linker and LLD both work. -fuse-ld=lld selects ld64.lld on macOS and ld.lld on Linux:
rllvm-cc -fuse-ld=lld lib.c app.c -o app
rllvm-get-bc app -o app.bc
See the LLD example.
Select lto_mode in the config or RLLVM_LTO_MODE, and use the same mode when compiling and linking:
| Mode | Captured bitcode | Support |
|---|---|---|
marker (default) | Per-source modules recorded in LTO objects | Full/ThinLTO; ELF/Mach-O; C/C++ |
save-temps | Full-LTO linker's merged, optimized module | Separate links and combined source/link invocations |
skip | No additional capture; emits a warning | Explicitly disabling capture for LTO |
RLLVM_LTO_MODE=save-temps rllvm-cc -flto hello.c -o hello
rllvm-get-bc hello -o hello.bc
save-temps needs real LTO inputs — adding -flto only at link time is not enough — and ThinLTO has no single merged module, so use marker for it. A static library of -flto objects extracts without linking: its members are bitcode, and each one is taken as a module. COFF and WebAssembly reject marker and direct you to skip.
See the LTO example.
Pass --target=<triple> as you would to Clang. The triple reaches the link as well as the compile, so a one-shot build works:
rllvm-cc --target=aarch64-unknown-linux-gnu -fuse-ld=lld -nostdlib \
lib.c app.c -o app
rllvm-get-bc app -o app.bc # app.bc carries the cross triple
The recorded section follows the object's format, not the host's, so a Linux ELF built on macOS extracts like any other. Linking ELF off a non-ELF host needs LLD, and libc needs a sysroot as usual. Set llvm_objcopy_filepath for targets the internal fallback does not model, such as RISC-V.
Universal (multiple -arch) builds are unsupported; build and extract one architecture at a time.
See the cross-compilation example.
rllvm-cc --target=wasm32-unknown-unknown -nostdlib -Wl,--no-entry \
lib.o main.o -o app.wasm
rllvm-get-bc app.wasm -o app.bc
rllvm-cc --target=bpf -O2 -g -c prog.c -o prog.o
rllvm-get-bc prog.o -o prog.bc
WebAssembly linking needs a matching wasm-ld from LLD; see the WebAssembly example. eBPF works without special handling: libbpf skips rllvm's section on load and preserves it through linking. That linker requires BTF, so compile with -g; see the eBPF example.
rllvm-get-bc app # one whole-program .bc
rllvm-get-bc --merge-strategy archive libfoo.a # writes libfoo.bca
rllvm-get-bc --merge-strategy partial app # merge by directory, then combine
rllvm-get-bc -m app # also write app.bc.manifest
rllvm-get-bc accepts executables, objects and archives, and -o chooses the output path. The default links every module into one .bc; -b is shorthand for archive mode. Archive outputs are rewritten from the current modules, so removed members do not persist.
See the merge strategies example.
A catalog is a JSON record of which modules were found and where they came from. Use one to select a subset, or to feed queries:
rllvm-info app --json > catalog.json
rllvm-get-bc app --module MODULE_ID --output-dir analysis/selected
rllvm-get-bc analysis/selected/catalog.json -o selected.bc
--module, --source and --configuration are repeatable: alternatives within one option combine, different options intersect, and an unmatched selector fails. --output-dir must be new, and copies hash-checked modules into it with relative paths so the directory can move.
A catalog describes the evidence it collected and the scope it selected — not proven whole-program completeness. See the format reference and the catalogs example.
Recorded paths are absolute by default. Record them relative to a root instead, then supply that root's new location:
RLLVM_BITCODE_ROOT="$PWD/build" cmake --build build
# after moving build/ to moved-build/:
rllvm-get-bc --bitcode-root moved-build moved-build/my_program
The root must contain the bitcode files, including a central bitcode_store_path if you use one. A root that cannot contain the bitcode is reported rather than silently ignored. See the relocatable example.
rllvm-query answers source-level questions about captured bitcode. It links LLVM statically, so it ships as its own crate and formula:
brew install h1994st/tap/rllvm-query # brings its own llvm
# Or: cargo install rllvm-query
# If llvm-config is not on PATH: LLVM_SYS_231_PREFIX="$(brew --prefix llvm)"
On Ubuntu/Debian, install libpolly-N-dev alongside llvm-N-dev and libclang-N-dev.
rllvm-query --catalog catalog.json defs parse_frame # where it is defined
rllvm-query --catalog catalog.json at parser.c 4 # what is at a source line
rllvm-query --catalog catalog.json callers parse_frame # who calls it
rllvm-query --catalog catalog.json callees main # what it calls
rllvm-query --catalog catalog.json uses parse_frame # where its address is taken
rllvm-query --catalog catalog.json reach main parse_frame # a path between two functions
rllvm-query --catalog catalog.json slice main parse_frame # every function on such a path
rllvm-query --catalog catalog.json closure parse_frame in # everything that reaches it
rllvm-query --catalog catalog.json externals # unbound symbols
rllvm-query --catalog catalog.json ffi-exports # Rust functions C can call
rllvm-query --catalog catalog.json indirect-targets parser.c:8 # targets of an indirect call
rllvm-query --catalog catalog.json resolution-candidates # indirect calls grouped by field, with candidates
An indirect call site, and an address stored or placed in a table, names the record field it goes through (via ops@8 (on_event), into ops@8) when the IR proves one; the member name needs -g.
Answers print as text. Add --json for the full machine-readable envelope, or --full to print the scope, analysis, uncertainty and provenance blocks as text too. The MCP server always speaks JSON.
Each invocation loads the whole catalog. To ask many questions, pipe them in, one per line, and the catalog loads once:
printf '%s\n' 'callees main' 'callers parse_frame' \
| rllvm-query --catalog catalog.json
Each answer follows a == <query> line; with --json, each is one line of JSON. Every line is checked before the catalog is read.
Each answer's analysis.cache (and --full) reports hits, misses and disk use. RLLVM_QUERY_CACHE=0 turns it off.
rllvm-query cache # the facts cache's location and disk use
rllvm-query cache clear # delete every cached entry
rllvm-query cache clear --stale # delete only generations this binary no longer reads
An answer whose facts cache is over query_cache_warn_mb adds a footer note naming rllvm-query cache clear.
Every answer carries the results plus what it could not see: which modules failed to parse, which call sites are indirect, which symbols bind ambiguously, and whether each location's source has changed since it was compiled. See the queries example and the staleness example.
A symbol can be named three ways — the mangled symbol, its demangled reading, or a bare identifier that searches:
rllvm-query --catalog catalog.json defs _Z5twiceIiET_S0_
rllvm-query --catalog catalog.json defs 'int twice<int>(int)'
rllvm-query --catalog catalog.json defs twice
See the name resolution example.
An agent that resolves an indirect call (from resolution-candidates, say) records it in an overlay: a JSON-lines file beside the catalog, <catalog stem>.overlay.jsonl unless --overlay PATH names another. The catalog itself is never changed.
echo '{"op":"add","via_field":{"record":"ops","offset":8},"to":"h3","confidence":"high","provenance":["init: o->on_event = h3"]}' \
| rllvm-query --catalog catalog.json overlay record # validate and append, all or none
rllvm-query --catalog catalog.json overlay list # edges, verdicts, sites covered
rllvm-query --catalog catalog.json overlay compact # rewrite as the current edges
An add names a field (via_field, every unresolved site through it) or one site, and attaches only where LLVM left the call unbounded. verify and retract name an edge's key. The file is bound to one build: after a rebuild it refuses to load, naming both fingerprints, as it does when written by an rllvm-query that numbers call sites differently, and a write refuses if another writer changed the file since it was read. To start again after a refusal, move or delete the file, or name another with --overlay (MCP: path). Overlay edges are hypotheses, never proof, and no answer uses them unless asked:
rllvm-query --catalog catalog.json reach main handler --include-overlay
rllvm-query --catalog catalog.json closure handler in --include-overlay --min-confidence medium
rllvm-query --catalog catalog.json slice main handler --include-overlay --emit-module slice.bc
--include-overlay makes reach, closure and slice also walk overlay edges, never refuted ones, and --min-confidence low|medium|high (default low) drops weaker ones. Each step through one is labeled agent with the edge's provenance, a path or slice using one ends not proven, and closure lists what only agent edges reach apart. An overlay file that cannot be read is an error, never a direct-only answer, as is a missing file that --overlay names. To check an edge in scope, slice --emit-module writes the slice's definitions as one small module, cut out with the llvm-extract and llvm-nm in the configured llvm_bindir and joined with the configured llvm-link; an alias comes with the function it stands for, and an empty slice writes nothing. See the call-graph overlay example.
rllvm-query mcp
Serves the same queries as JSON-RPC 2.0 tools over stdio. Point a client at it:
{
"mcpServers": {
"rllvm": {
"command": "rllvm-query",
"args": ["mcp"]
}
}
}
The client chooses what to analyze with load_catalog (a catalog JSON) or inventory (a binary, archive or .bc), and can keep several loaded at once. Each is analyzed once and answers from memory after that.
Four tools keep the call-graph overlay over MCP. record_edges takes the records overlay record reads, all or none, attaching the overlay beside the catalog first if none is; reach, closure and slice walk them at once with include_overlay (and min_confidence), and slice's emit_module needs a catalog from load_catalog. Only save_overlay writes, appending to the file. load_overlay attaches the default file or path (required after inventory) and refuses to drop unsaved records unless discard_pending is set; list_overlay shows the edges, saved or not. unload_catalog reports any unsaved records it dropped.
See the MCP example. In Claude Code, the plugin configures this server for you.
The TOML file lives at $RLLVM_CONFIG or ~/.rllvm/config.toml. rllvm-init --dry-run previews detection; --llvm-prefix chooses a toolchain and -o selects the file to write.
| Key | Required | Description |
|---|---|---|
llvm_config_filepath | Yes | Absolute path to llvm-config |
clang_filepath | Yes | Absolute path to clang |
clangxx_filepath | Yes | Absolute path to clang++ |
llvm_ar_filepath | Yes | Absolute path to llvm-ar |
llvm_link_filepath | Yes | Absolute path to llvm-link |
llvm_objcopy_filepath | No | Absolute path to llvm-objcopy; preferred for embedding, with an internal fallback |
llvm_bindir | No | Absolute path to the directory holding the LLVM tools without a key of their own (llvm-nm, llvm-dis, llvm-extract); rllvm-init records it (default: llvm-config --bindir) |
rustc_filepath | No | Compiler for direct Rust invocation; RLLVM_REAL_RUSTC overrides; defaults to rustc on PATH |
bitcode_store_path | No | Directory for bitcode files (must be absolute; created if missing) |
bitcode_root | No | Record embedded paths relative to this root (default: absolute) |
llvm_link_flags | No | Extra flags for llvm-link |
lto_ldflags | No | Extra flags for link-time optimization |
bitcode_generation_flags | No | Extra flags for bitcode generation (e.g. -flto) |
lto_mode | No | How -flto builds record bitcode: marker (default), save-temps, skip; RLLVM_LTO_MODE overrides |
is_configure_only | No | Skip extra C/C++ bitcode work (default: false) |
cache_enabled | No | Reuse C/C++ bitcode across rebuilds; overridden by RLLVM_CACHE (default: false) |
cache_dir | No | Cache directory; also holds rllvm-query's query-facts/ (default: ~/.rllvm/cache) |
query_cache | No | Reuse rllvm-query's extracted per-module facts; overridden by RLLVM_QUERY_CACHE (default: true) |
query_cache_warn_mb | No | Facts cache size, in MB, past which rllvm-query warns (default: 1024) |
log_level | No | 0=error (default), 1=warn, 2=info, 3=debug, 4+=trace; RLLVM_LOG_LEVEL overrides |
Clean build-only time relative to native compilation, on an Apple M4 with LLVM 23.1.2:
| Workload | Cache disabled | Primed C/C++ cache |
|---|---|---|
| nghttp2 C (CMake) | 1.86× | 1.65× |
| nghttp2 C++ (CMake) | 1.88× | 1.28× |
| Quiche (Cargo) | 1.28× | 1.23× |
C/C++ capture adds a bitcode compilation per source; Rust emits bitcode in the same rustc invocation. Unchanged rebuilds stay near native time, but extraction repeats its merge every run.
See the baseline for conditions and the benchmark guide for reproduction.
wllvm and gllvm built the wrapper-and-extract approach this tool follows.
rules_rllvm is a separate Bazel-native project and does not use these binaries.
hooks/register.tsx 411 lines1// A read-only view of rllvm-query's call-graph overlay. It draws only from
2// the results of the server's own tool calls, so records the agent has not
3// saved yet show as they are made; it never reads the overlay file, never
4// writes anything, and offers nothing to press.
5
6import { atom, read, update } from 'claude-code'
7import type { Register, StateDollar } from 'claude-code'
8
9import type {
10 OverlayConfidence,
11 OverlayEdge,
12 OverlayEdgeKey,
13 OverlayField,
14 OverlayFunction,
15 OverlayGroup,
16 OverlaySite,
17 OverlaySiteAt,
18 OverlayVerdict,
19 OverlayView,
20} from '../types'
21
22const PANE = 'rllvm-overlay'
23const TITLE = 'Call-graph overlay'
24const COMMAND = 'overlay-view'
25const EMPTY = 'Run resolution_candidates to see unresolved fields.'
26const GROUP_LIMIT = 50
27
28const CANDIDATES_TOOL = 'resolution_candidates'
29const SAVE_TOOL = 'save_overlay'
30const SUMMARY_TOOLS = ['record_edges', 'list_overlay', 'load_overlay']
31const OVERLAY_TOOLS = [CANDIDATES_TOOL, SAVE_TOOL, ...SUMMARY_TOOLS]
32
33const CONFIDENCES: readonly OverlayConfidence[] = ['low', 'medium', 'high']
34const VERDICT_MARKS: Readonly<Record<OverlayVerdict, string>> = {
35 confirmed: '✓',
36 refuted: '✗',
37 inconclusive: '?',
38}
39
40const groups = atom({ plugin: 'rllvm', key: 'groups' } as const, [])
41const droppedGroups = atom({ plugin: 'rllvm', key: 'dropped_groups' } as const, 0)
42const overlay = atom({ plugin: 'rllvm', key: 'overlay' } as const, null)
43
44type Json = Record<string, unknown>
45
46const isObject = (value: unknown): value is Json =>
47 typeof value === 'object' && value !== null && !Array.isArray(value)
48
49/**
50 * The rllvm-query tool a call went to, or undefined for any other tool. The
51 * server is named by whoever configured it (`plugin_rllvm_rllvm-query` from
52 * the rllvm plugin, anything by hand), so only the `mcp__` prefix and the
53 * `__<tool>` suffix are fixed.
54 */
55const overlayTool = (name: string): string | undefined =>
56 OVERLAY_TOOLS.find(tool => {
57 const suffix = `__${tool}`
58 return name.startsWith('mcp__') && name.endsWith(suffix) && name.length > 5 + suffix.length
59 })
60
61/** The JSON text an MCP result carries, whichever shape the engine handed it in. */
62const resultText = (ran: { text?: string; result?: unknown }): string | undefined => {
63 if (typeof ran.text === 'string') return ran.text
64 const result = ran.result
65 if (typeof result === 'string') return result
66 const blocks = Array.isArray(result) ? result : isObject(result) ? result.content : undefined
67 const first: unknown = Array.isArray(blocks) ? blocks[0] : undefined
68 return isObject(first) && typeof first.text === 'string' ? first.text : undefined
69}
70
71// Readers of the server's payloads. Each answers undefined for a value that
72// is not the shape the server sends, and a payload with any such value is
73// ignored whole, so the view never shows half of an answer.
74
75const allOf = <T,>(values: unknown, parse: (value: unknown) => T | undefined): T[] | undefined => {
76 if (!Array.isArray(values)) return undefined
77 const parsed = values.map(parse)
78 return parsed.every(value => value !== undefined) ? (parsed as T[]) : undefined
79}
80
81const parseFunction = (value: unknown): OverlayFunction | undefined =>
82 isObject(value) && typeof value.module_id === 'string' && typeof value.symbol === 'string'
83 ? { module_id: value.module_id, symbol: value.symbol }
84 : undefined
85
86const parseField = (value: unknown): OverlayField | undefined =>
87 isObject(value) && typeof value.record === 'string' && typeof value.offset === 'number'
88 ? { record: value.record, offset: value.offset }
89 : undefined
90
91const parseSite = (value: unknown): OverlaySite | undefined => {
92 if (!isObject(value)) return undefined
93 const fn = parseFunction(value.function)
94 const { block_index, instruction_index } = value
95 return fn && typeof block_index === 'number' && typeof instruction_index === 'number'
96 ? { function: fn, block_index, instruction_index }
97 : undefined
98}
99
100const parseLocation = (value: unknown): string | null =>
101 isObject(value) && typeof value.file === 'string' && typeof value.line === 'number'
102 ? `${value.file}:${value.line}`
103 : null
104
105const parseSiteAt = (value: unknown): OverlaySiteAt | undefined => {
106 const site = isObject(value) ? parseSite(value.site) : undefined
107 return site && isObject(value) ? { site, location: parseLocation(value.location) } : undefined
108}
109
110const parseGroup = (value: unknown): OverlayGroup | undefined => {
111 if (!isObject(value) || typeof value.signature !== 'string') return undefined
112 const field = value.field === undefined || value.field === null ? null : parseField(value.field)
113 const sites = allOf(value.sites, parseSiteAt)
114 const candidates = allOf(value.candidates, one =>
115 isObject(one) ? parseFunction(one.function) : undefined,
116 )
117 if (field === undefined || !sites || !candidates) return undefined
118 return {
119 field,
120 field_name: typeof value.field_name === 'string' ? value.field_name : null,
121 signature: value.signature,
122 sites,
123 candidates,
124 single_candidate: value.single_candidate === true,
125 }
126}
127
128const parseKey = (value: unknown): OverlayEdgeKey | undefined => {
129 if (!isObject(value)) return undefined
130 const to = parseFunction(value.to)
131 if (!to) return undefined
132 if ('via_field' in value) {
133 const via_field = parseField(value.via_field)
134 return via_field && { via_field, to }
135 }
136 const site = parseSite(value.site)
137 return site && { site, to }
138}
139
140const parseEdge = (value: unknown): OverlayEdge | undefined => {
141 if (!isObject(value)) return undefined
142 const key = parseKey(value.key)
143 const confidence = CONFIDENCES.find(one => one === value.confidence)
144 const verdict = isObject(value.verification) ? value.verification.verdict : null
145 const isVerdict =
146 verdict === null || (typeof verdict === 'string' && Object.hasOwn(VERDICT_MARKS, verdict))
147 if (!key || !confidence || !isVerdict || typeof value.sites !== 'number') return undefined
148 return { key, confidence, verdict: verdict as OverlayVerdict | null, sites: value.sites }
149}
150
151const parseSummary = (value: unknown): OverlayView | undefined => {
152 if (!isObject(value) || !isObject(value.coverage)) return undefined
153 const { unresolved_sites, covered_sites } = value.coverage
154 const edges = allOf(value.edges, parseEdge)
155 const path =
156 value.path === undefined || typeof value.path === 'string' ? (value.path ?? null) : undefined
157 if (
158 !edges ||
159 path === undefined ||
160 typeof value.pending !== 'number' ||
161 typeof unresolved_sites !== 'number' ||
162 typeof covered_sites !== 'number'
163 ) {
164 return undefined
165 }
166 return { path, pending: value.pending, edges, unresolved_sites, covered_sites }
167}
168
169/** Keeps what a tool's answer says about the overlay; ignores anything else. */
170const remember = async ($: StateDollar, tool: string, payload: unknown) => {
171 if (tool === CANDIDATES_TOOL) {
172 const parsed = isObject(payload) ? allOf(payload.results, parseGroup) : undefined
173 if (!parsed) return
174 // The server lists field groups first, the ones an edge can cover most
175 // sites through, so the first are kept and the rest only counted.
176 await update($, groups, () => parsed.slice(0, GROUP_LIMIT))
177 await update($, droppedGroups, () => Math.max(0, parsed.length - GROUP_LIMIT))
178 } else if (tool === SAVE_TOOL) {
179 if (!isObject(payload) || typeof payload.saved !== 'number' || typeof payload.path !== 'string')
180 return
181 const { saved, path } = payload
182 await update(
183 $,
184 overlay,
185 view => view && { ...view, path, pending: Math.max(0, view.pending - saved) },
186 )
187 } else {
188 const parsed = parseSummary(payload)
189 if (parsed) await update($, overlay, () => parsed)
190 }
191}
192
193// Labels.
194
195const fieldLabel = (field: OverlayField) => `${field.record}@${field.offset}`
196
197const siteId = (site: OverlaySite) =>
198 `${site.function.module_id}:${site.function.symbol}:${site.block_index}:${site.instruction_index}`
199
200const sameFunction = (a: OverlayFunction, b: OverlayFunction) =>
201 a.module_id === b.module_id && a.symbol === b.symbol
202
203const plural = (count: number, noun: string) => `${count} ${noun}${count === 1 ? '' : 's'}`
204
205const edgeMark = (edge: OverlayEdge) =>
206 `${edge.confidence} ${edge.verdict ? VERDICT_MARKS[edge.verdict] : 'unverified'}`
207
208/** A function's symbol, with its module when another listed function shares the symbol. */
209const functionLabel = (fn: OverlayFunction, among: readonly OverlayFunction[]) =>
210 among.some(other => other.symbol === fn.symbol && other.module_id !== fn.module_id)
211 ? `${fn.symbol} (${fn.module_id.slice(0, 8)})`
212 : fn.symbol
213
214type Line = { key: string; text: string; detail?: string; isDim?: boolean; isBold?: boolean }
215
216/** One group's lines: its header, then each candidate, marked when an edge claims it. */
217const groupLines = (group: OverlayGroup, edges: readonly OverlayEdge[]): Line[] => {
218 // A group with no field is named by its first site, where the call is.
219 const first = group.sites[0]
220 const where = first ? [first.site.function.symbol, first.location ?? ''].join(' ').trim() : ''
221 const id = group.field
222 ? fieldLabel(group.field)
223 : `site-${first ? siteId(first.site) : group.signature}`
224 const head = group.field
225 ? `${fieldLabel(group.field)}${group.field_name ? ` (${group.field_name})` : ''}`
226 : `${where || 'sites'} (${group.signature})`
227 const details = [plural(group.sites.length, 'site')]
228 if (group.field) details.push(group.signature)
229 if (group.single_candidate) details.push('single candidate')
230 const sites = new Set(group.sites.map(one => siteId(one.site)))
231 const claims = edges.filter(edge =>
232 'via_field' in edge.key
233 ? group.field !== null && fieldLabel(edge.key.via_field) === fieldLabel(group.field)
234 : group.field === null && sites.has(siteId(edge.key.site)),
235 )
236 // An edge to a function the candidates do not list still belongs here.
237 const targets = [
238 ...group.candidates,
239 ...claims
240 .map(edge => edge.key.to)
241 .filter(to => !group.candidates.some(one => sameFunction(one, to))),
242 ]
243 return [
244 { key: `group-${id}`, text: head, detail: ` · ${details.join(' · ')}`, isBold: true },
245 ...targets.map(target => {
246 const label = functionLabel(target, targets)
247 const edge = claims.find(one => sameFunction(one.key.to, target))
248 return edge
249 ? {
250 key: `candidate-${id}-${label}`,
251 text: `→ ${label} ${edgeMark(edge)}`,
252 isDim: edge.verdict === 'refuted',
253 }
254 : { key: `candidate-${id}-${label}`, text: ` ${label}` }
255 }),
256 ]
257}
258
259/** Field edges whose field no group lists, one block per field. */
260const orphanFieldBlocks = (
261 fieldEdges: readonly OverlayEdge[],
262 known: ReadonlySet<string>,
263): Line[][] => {
264 const byField = new Map<string, OverlayEdge[]>()
265 for (const edge of fieldEdges) {
266 if (!('via_field' in edge.key)) continue
267 const label = fieldLabel(edge.key.via_field)
268 if (!known.has(label)) byField.set(label, [...(byField.get(label) ?? []), edge])
269 }
270 return [...byField].map(([label, edges]) => [
271 { key: `group-${label}`, text: label, isBold: true },
272 ...edges.map(edge => {
273 const name = functionLabel(
274 edge.key.to,
275 edges.map(one => one.key.to),
276 )
277 return {
278 key: `candidate-${label}-${name}`,
279 text: `→ ${name} ${edgeMark(edge)}`,
280 isDim: edge.verdict === 'refuted',
281 }
282 }),
283 ])
284}
285
286/** Edges recorded for one call site, with the site's source line when a group named it. */
287const siteBlock = (
288 edges: readonly OverlayEdge[],
289 at: ReadonlyMap<string, string | null>,
290): Line[] => {
291 const lines = edges.flatMap(edge => {
292 if (!('site' in edge.key)) return []
293 const { site, to } = edge.key
294 const where = at.get(siteId(site)) ?? `b${site.block_index}:i${site.instruction_index}`
295 const text = `${site.function.symbol} ${where} → ${functionLabel(to, [])} ${edgeMark(edge)}`
296 return [{ text, isDim: edge.verdict === 'refuted' }]
297 })
298 return lines.length
299 ? [
300 { key: 'sites', text: 'sites', isBold: true },
301 ...lines.map((line, index) => ({ key: `site-${index}`, ...line })),
302 ]
303 : []
304}
305
306const coverageLine = (view: OverlayView | null): Line =>
307 view
308 ? {
309 key: 'coverage',
310 text: [
311 `covered ${view.covered_sites} of ${view.unresolved_sites} unresolved sites`,
312 plural(view.edges.length, 'edge'),
313 `${view.pending} unsaved`,
314 ].join(' · '),
315 }
316 : { key: 'coverage', text: 'no overlay loaded yet', isDim: true }
317
318/**
319 * Everything the pane shows, cut to `rows` lines with a count of the blocks
320 * left out, `dropped` groups never kept among them.
321 */
322const paneLines = (
323 list: readonly OverlayGroup[],
324 dropped: number,
325 view: OverlayView | null,
326 rows: number,
327): Line[] => {
328 const edges = view?.edges ?? []
329 const head = [coverageLine(view)]
330 if (view?.path) head.push({ key: 'path', text: view.path, isDim: true })
331 if (list.length === 0) head.push({ key: 'empty', text: EMPTY, isDim: true })
332
333 const known = new Set(list.flatMap(group => (group.field ? [fieldLabel(group.field)] : [])))
334 const at = new Map(
335 list.flatMap(group => group.sites.map(one => [siteId(one.site), one.location] as const)),
336 )
337 const blocks = [
338 ...list.map(group => groupLines(group, edges)),
339 ...orphanFieldBlocks(edges, known),
340 siteBlock(edges, at),
341 ].filter(block => block.length > 0)
342
343 const more = (count: number): Line => ({ key: 'more', text: `…${count} more`, isDim: true })
344 const shown = [...head]
345 for (const [index, block] of blocks.entries()) {
346 const isLast = index === blocks.length - 1 && dropped === 0
347 if (shown.length + block.length + (isLast ? 0 : 1) > rows) {
348 shown.push(more(blocks.length - index + dropped))
349 return shown
350 }
351 shown.push(...block)
352 }
353 if (dropped > 0) shown.push(more(dropped))
354 return shown
355}
356
357export const register: Register = on => {
358 on('session.start', async ($, e, next) => {
359 await $.command.register({
360 name: COMMAND,
361 description: "Show rllvm-query's call-graph overlay in a pane",
362 })
363 return next(e)
364 })
365
366 on('command.run', { command: COMMAND }, async $ => {
367 await $.ui.open({ id: PANE, title: TITLE })
368 return { text: 'Call-graph overlay pane opened.' }
369 })
370
371 on('tool.call', async ($, e, next) => {
372 const tool = overlayTool(String(e.tool))
373 if (!tool) return next(e)
374 const ran = await next(e)
375 if (ran.deny !== undefined || ran.isError === true) return ran
376 const text = resultText(ran)
377 if (text === undefined) return ran
378 let payload: unknown
379 try {
380 payload = JSON.parse(text)
381 } catch {
382 return ran
383 }
384 await remember($, tool, payload)
385 return ran
386 })
387
388 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
389 const { Box, Text } = $.ui.resolve(e)
390 const rows = e.props.scroll?.bodyRows ?? e.viewport?.rows ?? Number.POSITIVE_INFINITY
391 const lines = paneLines(
392 await read($, groups),
393 await read($, droppedGroups),
394 await read($, overlay),
395 Math.max(rows, 1),
396 )
397 return (
398 <Box flexDirection="column">
399 {lines.map(line => (
400 <Box key={line.key}>
401 <Text dimColor={line.isDim === true} bold={line.isBold === true} wrap="truncate-end">
402 {line.text}
403 {line.detail ? <Text dimColor>{line.detail}</Text> : null}
404 </Text>
405 </Box>
406 ))}
407 </Box>
408 )
409 })
410}
411types/index.d.ts 66 lines1// What the overlay view keeps in `$.state`: the last answers of
2// rllvm-query's MCP tools, reduced to what the pane draws. Nothing here is
3// read from disk; unsaved records show because they arrive in tool results.
4
5/** A function as rllvm-query names it: the module it is defined in, and its symbol. */
6export type OverlayFunction = { module_id: string; symbol: string }
7
8/** A struct field by record name and byte offset (`ops@8`). */
9export type OverlayField = { record: string; offset: number }
10
11/** One unresolved indirect call site. */
12export type OverlaySite = {
13 function: OverlayFunction
14 block_index: number
15 instruction_index: number
16}
17
18/** A site and where it is in the source, when the build recorded that. */
19export type OverlaySiteAt = { site: OverlaySite; location: string | null }
20
21/** One `resolution_candidates` group: a field (or a set of sites) and the functions it may call. */
22export type OverlayGroup = {
23 field: OverlayField | null
24 field_name: string | null
25 signature: string
26 sites: OverlaySiteAt[]
27 candidates: OverlayFunction[]
28 single_candidate: boolean
29}
30
31/** An overlay edge's identity: a field or a single site, and the function it calls. */
32export type OverlayEdgeKey =
33 { via_field: OverlayField; to: OverlayFunction } | { site: OverlaySite; to: OverlayFunction }
34
35export type OverlayConfidence = 'low' | 'medium' | 'high'
36
37export type OverlayVerdict = 'confirmed' | 'refuted' | 'inconclusive'
38
39/** One agent-recorded edge and what has been checked about it. */
40export type OverlayEdge = {
41 key: OverlayEdgeKey
42 confidence: OverlayConfidence
43 verdict: OverlayVerdict | null
44 sites: number
45}
46
47/** The overlay as the last summary reported it. */
48export type OverlayView = {
49 path: string | null
50 pending: number
51 edges: OverlayEdge[]
52 unresolved_sites: number
53 covered_sites: number
54}
55
56declare module 'claude-code' {
57 interface PluginState {
58 rllvm: {
59 groups: OverlayGroup[]
60 /** Groups the last `resolution_candidates` listed beyond those kept. */
61 dropped_groups: number
62 overlay: OverlayView | null
63 }
64 }
65}
66