Producer — the analyzer
Runs out-of-band as a CI / Kubernetes Job or CronJob from the fat JAR, pushing app-scoped subgraphs into a managed or clustered Neo4j over Bolt. These are the heavy pods — they build the project and run WALA.
By default codeanalyzer-java writes one analysis.json per project. That file is self-contained, but it doesn’t compose: to ask a question across a portfolio you load every blob into memory and stitch them together yourself. --emit neo4j projects the same symbol table and call graph into a Neo4j property graph instead — a queryable, persistent system of record that many applications can share, and that downstream tools read with Cypher rather than by parsing giant JSON files.
:JEntrypoint;
the J_CALLS edge is the resolved call graph.
The projection is lossless: every entity the analyzer extracts — compilation units, types, callables, fields, parameters, call sites, variables, enum constants, record components, initialization blocks, CRUD operations and queries, comments, annotations, and packages — becomes a first-class node or relationship. All node labels are J-prefixed and all relationship types are J_-prefixed, so a Java graph can share one Neo4j database with the Python (Py* / PY_*) and TypeScript (TS* / TS_*) backends without colliding. For the full label and relationship inventory, see the graph-schema reference.
--emit neo4j has two sub-modes, chosen purely by whether a Bolt URI resolved — from --neo4j-uri or the NEO4J_URI environment variable:
flowchart TD
A["--emit neo4j"] --> B{"Bolt URI resolved?<br/>(--neo4j-uri or NEO4J_URI)"}
B -->|"no"| C["CypherWriter<br/>writes graph.cypher"]
C --> C1["constraints + indexes"]
C --> C2["scoped wipe of this app's<br/>prior subgraph"]
C --> C3["batched UNWIND ... MERGE<br/>(BATCH = 500)"]
C --> C4["load later:<br/>cypher-shell < graph.cypher"]
B -->|"yes"| D["BoltWriter<br/>pushes over Bolt"]
D --> D1["ensure constraints + indexes"]
D --> D2["diff each unit's content_hash<br/>vs the live DB"]
D --> D3["replace only changed units'<br/>subgraphs (MERGE, BATCH = 1000)"]
D --> D4{"full run?"}
D4 -->|"yes"| D5["prune units whose<br/>source file vanished"]
D4 -->|"no (-t targeted)"| D6["skip orphan pruning"]
graph.cypher snapshot. A self-contained, re-runnable Cypher file expressing the full truth of this run. Good for review, version control, air-gapped loads, and CI artifacts.graph.cypher snapshotWith no Bolt URI, codeanalyzer renders a CypherWriter snapshot to <output>/graph.cypher (defaulting to the current working directory if -o is omitted):
java -jar codeanalyzer-2.3.7.jar \ -i /path/to/project -a 2 \ --emit neo4j \ --app-name daytrader8 \ -o ./out# -> ./out/graph.cypherThe file is a single, ordered, re-runnable script that:
MATCH (a:JApplication {name: 'daytrader8'}) then DETACH DELETE its units and their descendants. Shared :JPackage and :JAnnotation nodes are left intact so other applications keep theirs.UNWIND ... MERGE (BATCH = 500).Because the snapshot expresses full truth and wipes-then-reloads, it is not incremental — re-running it replaces this app’s subgraph wholesale. Load it whenever you’re ready:
cypher-shell -a bolt://localhost:7687 -u neo4j < ./out/graph.cypherThe scoped wipe means the snapshot is safe to load into a database that already hosts other applications: only the matching :JApplication anchor and its descendants are touched.
When a Bolt URI resolves, the BoltWriter connects over the official neo4j-java-driver and updates the graph in place. Prefer the NEO4J_PASSWORD environment variable over --neo4j-password so the secret never lands in shell history or process listings:
export NEO4J_URI=bolt://localhost:7687export NEO4J_USERNAME=neo4jexport NEO4J_PASSWORD=secret # keep credentials out of argv
java -jar codeanalyzer-2.3.7.jar \ -i /path/to/project -a 2 \ --emit neo4j \ --app-name daytrader8Everything resolves with a consistent precedence — flag > environment variable > default:
| Setting | Flag | Environment | Default |
|---|---|---|---|
| Bolt URI | --neo4j-uri | NEO4J_URI | (none → snapshot mode) |
| Username | --neo4j-user | NEO4J_USERNAME | neo4j |
| Password | --neo4j-password | NEO4J_PASSWORD | neo4j |
| Database | --neo4j-database | NEO4J_DATABASE | (server default) |
The database is only pinned (SessionConfig.forDatabase) when you set it non-null; otherwise the driver uses the server’s default database.
The push is genuinely incremental, not a wipe-and-reload. For each compilation unit, BoltWriter:
content_hash. Each unit carries a SHA-256 over its source. The writer reads the live DB’s current state and compares — units whose hash is unchanged are skipped entirely.MERGE upserts (BATCH = 1000), so re-running the same analysis is a no-op against an up-to-date graph.MERGE-only. :JPackage and :JAnnotation are shared across applications; they are merged, never deleted, so concurrent app writers don’t clobber each other.-t), units whose source file has vanished are removed. On a targeted run (-t / --target-files), pruning is skipped — a targeted run only knows about the files you named, so it replaces just those subgraphs and leaves everything else alone.This is what makes the graph cheap to keep current: a CI job that re-analyzes on every push touches only the units that actually changed.
--app-name is the tenancy key. It sets the name of the single :JApplication anchor node (a uniqueness constraint guarantees one per name), and every analyzed compilation unit hangs off it via (:JApplication {name})-[:J_HAS_UNIT]->(:JCompilationUnit). Both emit modes scope all of their writes — the snapshot’s wipe and the Bolt push’s upserts and pruning — to that one anchor.
If you omit --app-name, it defaults to the base name of the -i input directory (or the literal application when there is no input). Because the wipe and the push are app-scoped, many applications coexist in one database, each rooted at its own anchor:
// list every application living in this databaseMATCH (a:JApplication)RETURN a.name AS application, a.schema_version AS schemaORDER BY application;Cross-service questions become a graph traversal instead of a memory problem. A whole-portfolio query never loads anything it doesn’t need:
// which applications declare a type that implements javax.servlet.Filter?MATCH (a:JApplication)-[:J_HAS_UNIT]->(:JCompilationUnit) -[:J_DECLARES_TYPE]->(t:JType)WHERE 'javax.servlet.Filter' IN t.implements_listRETURN DISTINCT a.name AS application, t.fqn AS filterORDER BY application;Every emitted graph stamps schema_version on its :JApplication node. The current schema version is 1.0.0. Read it straight off the anchor to confirm what contract a given application was loaded under:
MATCH (a:JApplication {name: 'daytrader8'})RETURN a.schema_version; // -> "1.0.0"--emit schema publishes the machine-readable schema contract — the catalog of every label, relationship, and property the projector is allowed to emit — and runs no project analysis (it short-circuits before any source is parsed, so it needs no -i):
# print the contract to stdoutjava -jar codeanalyzer-2.3.7.jar --emit schema
# or write it to a filejava -jar codeanalyzer-2.3.7.jar --emit schema -o ./out# -> ./out/schema.neo4j.jsonThe contract ships as DDL inside the analyzer: uniqueness constraints (including a global :JSymbol.id identity), plus indexes — among them a fulltext index (j_code_fts) over JCallable.code and JCallable.docstring, so you can full-text search source from Cypher:
CALL db.index.fulltext.queryNodes('j_code_fts', 'executeQuery')YIELD node, scoreRETURN node.signature, scoreORDER BY score DESCLIMIT 10;A conformance test (Neo4jSchemaConformanceTest, no container required) asserts that the projector never emits an undeclared label, relationship, or property, and that schema.neo4j.json is current — so the contract you read is the contract the graph honors. For the full topology, see the graph-schema reference.
The Neo4j output naturally divides into a producer and consumers:
Producer — the analyzer
Runs out-of-band as a CI / Kubernetes Job or CronJob from the fat JAR, pushing app-scoped subgraphs into a managed or clustered Neo4j over Bolt. These are the heavy pods — they build the project and run WALA.
Consumers — the readers
Agents, the CLDK Python SDK, and dashboards are lightweight, read-only Bolt clients. They never build or analyze anything; they query the graph and scale independently of the analysis pods.
Many analyzer jobs write into one shared cluster — each anchored at its own :JApplication — and reads fan out from it. Give consumers read-only credentials; the SDK and Cypher dashboards need nothing more. For high availability, point producers at Neo4j Aura or an Enterprise cluster. Because the push is incremental and idempotent, a CronJob can re-run safely on a schedule without ever rebuilding the whole graph.
The big payoff: analysis is produced once, centrally, and read cheaply everywhere. CLDK has a read-only Neo4j backend that reconstructs the same typed model objects and the same networkx call graph as the in-process analyzer — with no JDK, no native binary, and no project source on the consumer. It needs only the Bolt URI and read-only credentials.
Install the driver extra:
pip install cldk[neo4j] # or: pip install neo4jSelect the backend by passing a Neo4jConnectionConfig to the CLDK.java(...) factory. The application_name here must match the --app-name the graph was loaded with — that’s how the SDK scopes every query back to the right :JApplication:
# Java project — read-only Neo4j backendfrom cldk import CLDKfrom cldk.analysis import AnalysisLevelfrom cldk.analysis.commons.backend_config import Neo4jConnectionConfig
analysis = CLDK.java( analysis_level=AnalysisLevel.call_graph, backend=Neo4jConnectionConfig( uri="bolt://localhost:7687", username="neo4j", password="neo4j", # read-only credentials are sufficient application_name="daytrader8", # == the CLI --app-name ),)
symbol_table = analysis.get_symbol_table() # Dict[str, JCompilationUnit]cg = analysis.get_call_graph() # networkx.DiGraphklass = analysis.get_class("com.example.MyService")methods = analysis.get_methods_in_class("com.example.MyService")The backend bulk-fetches nodes and relationships in a handful of Cypher queries and rebuilds the canonical JApplication — the same shape the in-process analyzer produces — so get_* returns identical JType / JCallable objects and the same call graph. Available methods include get_symbol_table(), get_call_graph(), get_classes(), get_class(), get_methods(), get_methods_in_class(), get_callers(), get_callees(), get_entry_point_methods(), and get_all_crud_operations().
-t / --target-files works, and why it forces level 1.-a 1 vs. -a 2 compute, and why only level 2 emits J_CALLS.