Skip to content

Repository files navigation

Graphite

License

Structured codebase context for LLMs. Graphite turns JVM bytecode into a queryable program graph — so AI agents can understand your codebase without reading every file.

The Problem

LLMs working with code face a fundamental constraint: context windows are finite, but codebases are not.

Dumping source files into a prompt is wasteful. Most tokens describe boilerplate, imports, and formatting — not the relationships that matter. An LLM trying to understand "what calls this method?" or "what constants flow into this API?" must read hundreds of files to answer questions that a graph can answer in milliseconds.

The Solution

Graphite builds a program graph from compiled bytecode — nodes are program elements (methods, fields, constants, call sites), edges are relationships (dataflow, calls, type hierarchy). LLMs query the graph instead of reading source code.

Before Graphite: Feed 500 source files (~2M tokens) to find AB test IDs. With Graphite: Query graph.callSites(pattern) → get 23 constants in 12 tokens.

What the Graph Captures

RelationshipExampleLLM Use Case
Dataflowx = 42; foo(x) → constant 42 flows to fooTrack config values, feature flags, API keys
Call graphUserService.save() calls Repository.insert()Understand execution paths without reading source
Type hierarchyAdminUser extends User implements AuditableResolve polymorphism, find implementations
Annotations@GetMapping("/api/users") on listUsers()Discover endpoints, serialization rules, DI config
Lambda/method refitems.stream().map(User::getName)Trace functional pipelines
Resourcesconfig/application.yml inside a fat JARCross-reference code with config files

Token Efficiency

TaskRaw SourceGraphite QueryReduction
Find all AB test IDs~500 files, 2M tokenscallSites + backwardSlice → 23 results99.99%
Map REST endpoints~200 controllers, 800K tokensmemberAnnotations scan → structured list99.9%
Find dead codeEntire codebase, 5M tokensbranchScopes + callSites → dead paths99.99%
Resolve type hierarchy~100 files per type chainsupertypes / subtypes → direct answer99%

Graphite uses Cypher (the industry-standard graph query language) for querying. The Cypher engine is in the graphite-cypher module, powered by an ANTLR-based openCypher parser.

Why Not Tree-sitter?

Tools like GitNexus, Aider, and most LLM code assistants use Tree-sitter for codebase understanding. Tree-sitter parses syntax — it sees text structure, not program semantics.

CapabilityTree-sitterGraphite
"What type is this variable?"No — sees var x = foo(), can't resolve foo's return typeYes — full type resolution from bytecode
"What values flow into this parameter?"No — can't cross method boundariesYes — inter-procedural backward slice
"Does this interface have implementations?"Heuristic grep for class namesYes — complete type hierarchy from class metadata
"What does this lambda actually call?"No — invokedynamic is invisible in sourceYes — MethodHandle extraction from bootstrap args
"Is this field used via reflection/DI?"No — annotation semantics are opaqueYes — annotation values are queryable data
"What's the real type of Object fields?"No — requires dataflow across methodsYes — cross-method field assignment tracking
Controller inheritanceNo — can't resolve inherited annotationsYes — walks type hierarchy for endpoint discovery

The fundamental issue: Tree-sitter operates on syntax (one file at a time, no type resolution, no cross-file dataflow). Graphite operates on semantics (compiled bytecode with full type information, inter-procedural analysis, resolved generics).

For LLMs, this difference is critical. A syntax tree tells you what code looks like. A program graph tells you what code does.

Quick Start

# Install via Homebrew
brew tap johnsonlee/tap
brew install graphite
# Build a graph from your JAR
graphite build app.jar -o /data/app-graph --include com.example
# Build a graph from an Android APK
graphite build app.apk \
-o /data/apk-graph \
--include com.example
# Query with Cypher
graphite query /data/app-graph \
"MATCH (c:IntConstant)-[:DATAFLOW*]->(cs:CallSiteNode) WHERE cs.callee_class =~ 'com.example.*' RETURN c.value, cs.callee_name"# JSON output (for LLM consumption)
graphite query --format json /data/app-graph \
"MATCH (n:CallSiteNode) RETURN n.callee_name LIMIT 10"# Launch the web UI
graphite serve --id app /data/app-graph --port 8080
# Serve multiple graphs by id. Relative graph paths resolve under --data.
graphite serve --data /data/graphs \
--graph orders:orders-graph \
--graph billing:/data/billing-graph \
--port 8080
# Hot-load or replace a graph without restarting the server
curl -X PUT http://localhost:8080/api/graphs/orders \
-H 'Content-Type: application/json' \
-d '{"path":"/data/graphs/orders-graph-v2"}'

For APK inputs, Graphite uses Android platform jars to resolve the APK's target API level. Pass --android-sdk with the Android SDK root. If omitted, Graphite searches in this order:

  1. ANDROID_HOME, then ANDROID_SDK_ROOT.
  2. Default SDK roots for the current OS:
    • macOS: ~/Library/Android/sdk, /opt/homebrew/share/android-commandlinetools, /usr/local/share/android-commandlinetools
    • Linux: ~/Android/Sdk, ~/android-sdk, /opt/android-sdk, /usr/local/android-sdk, /usr/lib/android-sdk
    • Windows: %USERPROFILE%\AppData\Local\Android\Sdk
  3. SDK roots inferred from adb, emulator, or sdkmanager on PATH.

Kotlin API

Build & Query

// Build graph from bytecodeval graph =JavaProjectLoader(LoaderConfig(
includePackages =listOf("com.example")
)).load(Path.of("/path/to/app.jar"))
// Cypher queryval result = graph.query(""" MATCH (c:IntConstant)-[:DATAFLOW*]->(cs:CallSiteNode) WHERE cs.callee_class =~ 'com.example.*' RETURN c.value, cs.callee_name""")
result.rows.forEach { row ->println("${row["c.value"]} -> ${row["cs.callee_name"]}")
}
// Programmatic query DSLval results =Graphite.from(graph).query {
findArgumentConstants {
method {
declaringClass ="com.example.ab.AbClient"
name ="getOption"
}
argumentIndex =0
}
}
// Annotations, dataflow analysisval annotations = graph.memberAnnotations("com.example.User", "name")
val slice =DataFlowAnalysis(graph).backwardSlice(nodeId)
slice.constants() // all constant values that reach this node

Persist & Load

// Save to disk (WebGraph compressed format)GraphStore.save(graph, Path.of("/data/app-graph"))
// Load — auto-adaptive based on graph size:// < 1M nodes → eager (all in heap, fastest queries)// >= 1M nodes → mmap (nodes off heap, 75% less memory)val graph =GraphStore.load(Path.of("/data/app-graph"))
// Or force a specific strategyval graph =GraphStore.load(dir, GraphStore.LoadMode.EAGER) // always in-heapval graph =GraphStore.load(dir, GraphStore.LoadMode.MAPPED) // always mmap

Access Resources

graph.resources.list("**/*.xml").forEach { entry ->println(entry.path) // e.g., "config/application.yml"
}

Query Resources With Cypher

Resources are also indexed into the graph, so you can query them with Cypher and cross-reference them with call sites:

// Structured resource valuesMATCH (r:ResourceValue{key:"feature.mode"})
RETURNr.path, r.value// Nested JSON / XML valuesMATCH (r:ResourceValue)
WHEREr.keyIN ["feature.enabled", "service.endpoint", "service.@enabled"]
RETURNr.path, r.key, r.value// Which call sites read a specific keyMATCH (r:ResourceValue{key:"feature.mode"})-[:RESOURCE_LOOKUP]->(cs:CallSiteNode)
RETURNcs.caller_signature, cs.callee_signature// Resource files opened by codeMATCH (f:ResourceFile)-[e:RESOURCE_OPEN|RESOURCE_LOAD|RESOURCE_BUNDLE_CANDIDATE]->(cs:CallSiteNode)
RETURNf.path, e.kind, cs.caller_signature, cs.callee_signature

Resource relationships are exposed as dedicated edge types:

TypeMeaning
RESOURCE_CONTAINSResourceFile -> ResourceValue
RESOURCE_OPENResource file opened directly by code
RESOURCE_LOADResource content loaded by parsers/bundles
RESOURCE_BUNDLE_CANDIDATEResourceBundle.getBundle(...) candidate resolution
RESOURCE_LOOKUPConcrete key/value lookup (getProperty, getString, getObject)
RESOURCE_KEYSKey enumeration (getKeys)

Resource path indexing currently covers:

  • .properties
  • .yml / .yaml
  • Java properties XML (Properties.loadFromXML)
  • .json
  • generic .xml
  • ListResourceBundle / provider-backed class bundles via path-level class indexing

Generic JDK resource linking currently covers:

  • ClassLoader.getResource*
  • Properties.load(...)
  • Properties.loadFromXML(...)
  • PropertyResourceBundle(...)
  • ResourceBundle.getString/getObject/getKeys
  • ResourceBundle.getBundle(...) with locale-aware candidate resolution
  • common ResourceBundle.Control cases including FORMAT_*, no-fallback controls, and simple custom getFormats/getCandidateLocales overrides

Explore Resource APIs

graphite serve exposes resource-aware HTTP APIs for agents and tooling:

EndpointDescription
/api/graphsList loaded webgraphs with cached per-graph statistics and aggregate totals
/api/graphs/{graphId}Get, load, replace, or unload a webgraph by id
/api/graphs/{graphId}/...Query one explicit webgraph with the direct single-graph response shape
/api/cypherRun one Cypher query over the union of every loaded graph
/api/cypher/graphsRun one query over an explicit graph set, or explicitly fan out per graph
/api/nodes, /api/methods, ...Query every loaded graph; non-Cypher results are grouped by graphId
/api/resourcesList indexed resources in every graph, grouped by graphId
/api/resources/{path}Read every matching resource without path collisions, grouped by graphId
/api/endpointsExtract framework HTTP endpoints from every graph, grouped by graphId
/openapi.jsonMachine-readable OpenAPI document for the explore server
/swagger.jsonSwagger-compatible alias of the same API document

Graph-local node IDs are accepted only by graph-scoped routes such as /api/graphs/{graphId}/node/{id} and /api/graphs/{graphId}/subgraph?center={id}. The corresponding root routes do not exist because the same local ID can identify unrelated nodes in different graphs.

There is no default graph and no automatic graph selection. Root graph APIs always mean all loaded graphs; /api/graphs/{graphId}/... always means exactly one graph. Every root non-Cypher result is grouped by graphId, while every cross-graph Cypher row includes $metadata.graphIds and returned graph elements include qualified identities such as elementId = "orders:42".

For agent-driven discovery, probe /openapi.json first. It describes the full root-all and graph-scoped REST surface, including the two explicit modes of /api/cypher/graphs.

Architecture

graphite/
├── graphite-core/ # Graph interface, nodes, edges, analysis
├── graphite-cypher/ # Cypher query engine (ANTLR parser + executor)
├── graphite-sootup/ # SootUp bytecode → graph builder
├── graphite-webgraph/ # WebGraph disk persistence (BVGraph + LAW tools)
├── graphite-query/ # CLI: build, query, serve
└── graphite-explore/ # Explore HTTP routes and legacy standalone launcher

Storage Format

Graphs are persisted using the WebGraph ecosystem:

DataFormat
AdjacencyBVGraph (2-4 bits/edge)
Edge labelsByte array in BVGraph order
StringsFrontCodedStringList (prefix compression)
Node dataCompact binary with string table indices
MetadataCompact binary with string table indices

Analysis Capabilities

CapabilityDescription
Constant trackingDirect, local variable, field, cross-class, enum
Auto-boxingInteger.valueOf() transparent handling
Lambda / method refinvokedynamic → actual target resolution
Functional dispatchCallbacks, return values, fields, varargs, conditionals
Controller inheritanceEndpoint discovery follows class hierarchy
Generic type analysisApiResponse<PageData<User>> nested structure
Branch reachabilityDead code via condition constant analysis
AnnotationsGeneric memberAnnotations() for any framework
Cypher queriesgraph.query("MATCH ...") -- full openCypher read grammar
Resource accessFiles inside JAR/WAR/fat JAR (nested JARs)

Extension Mechanism

Pluggable via GraphiteExtension SPI (ServiceLoader):

classMyExtension : GraphiteExtension {
overridefunvisit(sootClass:SootClass, context:GraphiteContext) {
// Extract domain-specific metadata during graph building
context.addMemberAnnotation(className, memberName, annotationFqn, values)
}
}

Register in META-INF/services/io.johnsonlee.graphite.sootup.GraphiteExtension.

Installation

repositories {
mavenCentral()
}
dependencies {
implementation("io.johnsonlee.graphite:core:2.1.0")
implementation("io.johnsonlee.graphite:sootup:2.1.0")
// Optional: Cypher query support (graph.query("MATCH ..."))
implementation("io.johnsonlee.graphite:cypher:2.1.0")
// Optional: disk persistence (WebGraph format)
implementation("io.johnsonlee.graphite:webgraph:2.1.0")
}

MCP Integration

Connect LLMs to Graphite via Model Context Protocol:

npx graphite-mcp

Configure in Claude Code (~/.claude/settings.json):

{
"mcpServers": {
"graphite": {
"command": "npx",
"args": ["graphite-mcp"],
"env": { "GRAPHITE_URL": "http://localhost:8080" }
}
}
}

Start the Explorer first, then LLMs can query the graph:

# Start Explorer
graphite serve --id app /path/to/saved-graph
# The serve command defaults to --load-mode MAPPED for multi-graph heap stability.

You can also start with no initial graph and hot-load services later:

graphite serve --data /data/graphs
curl -X PUT http://localhost:8080/api/graphs/orders \
-H 'Content-Type: application/json' \
-d '{"path":"orders-graph"}'

Graph replacement is atomic for readers. Requests that already acquired the previous graph finish against that snapshot, requests acquired after the swap use the replacement, and the previous graph is closed only after its last request releases it. A replacement that fails to load leaves the current graph unchanged.

To run one query across an explicit graph set:

curl -X POST http://localhost:8080/api/cypher/graphs \
-H 'Content-Type: application/json' \
-d '{"query":"MATCH (n:IntConstant) RETURN n.value","graphs":["orders","billing"],"limit":100}'

The default mode is cross-graph: patterns, joins, filters, and aggregations operate once over the selected graph union. Every row reports all contributing graphs in $metadata.graphIds. To preserve independent per-graph execution, explicitly send "mode":"fanout"; only this mode accepts perGraphLimit and includeGraphRows. In both modes, limit caps the total response row count.

The MCP tools follow the same rule: omitting graph_id queries all graphs; providing graph_id selects exactly one graph. The cypher tool can also use graphs: ["orders", "billing"] for an explicit subset or all_graphs: true with mode: "cross-graph" or mode: "fanout".

LLMs can now use tools such as openapi, graphs, cypher, resources, resource, endpoints, c4, nodes, methods, call_sites, and annotations.

The explore server also exposes a single C4 architecture endpoint:

GET /api/architecture/c4?level=context|container|component|all
GET /api/architecture/c4?level=context|container|component|all&format=dsl
GET /api/architecture/c4?level=context|container|component|all&format=mermaid
GET /api/architecture/c4?level=context|container|component|all&format=plantuml

Agents can use it to retrieve code graph-derived C4 architecture views without guessing multiple endpoints. The default response is a Structurizr workspace JSON document. For text rendering, use format=dsl, format=mermaid, or format=plantuml.

License

Copyright 2026 Johnson Lee
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0

About

Structured codebase context for LLMs

Resources

Stars

12 stars

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages