Every CLDK analyzer emits two projections of the one structure
(skills/designing-cldk-changes/references/canonical-schema.md): the analysis.json tree and a
Neo4j graph. They are co-primary — building both is a first-class deliverable, not an
afterthought. The graph is not an ingestion of analysis.json; it is a projection of the same
node tree + edge overlays, selected by --emit neo4j. analysis.json is the SDK's default
contract; the graph is the queryable, incrementally-updatable surface. Java, Python, and TypeScript
analyzers all ship it.
Containment renders as edges. A graph DB has no nesting, so the schema's containment tree becomes
typed HAS_*/DECLARES relationships (HAS_MODULE, DECLARES, HAS_CALLABLE, HAS_FIELD,
HAS_CALLSITE, and at L3/L4 HAS_BODY_NODE/HAS_CFG_NODE), while the overlay edges (call_graph,
cfg, cdg, ddg, param_in/param_out, summary) become their own typed relationships. Node
labels are the v2 node kinds; the can:// id is the merge key. This is a near-identity
projection — the same nodes and edges, rendered as a property graph.
Neo4j stays optional at run time (you don't need a running DB to emit analysis.json): the driver
is a lazy/optional dependency (Python/TS import it on demand; Java loads it reflectively so GraalVM
native-image can prune it). "Co-primary" means the analyzer must be able to produce it, not that
every run does.
The flags (--emit, --neo4j-uri, --neo4j-user/-password/-database, --app-name, precedence
explicit > env > default) are specified in references/cli-contract.md. --emit schema is a
static artifact and must run without -i; every other target requires -i.
Analysis levels do not apply to the graph surface. --emit neo4j runs the analyzer at its
maximum implemented depth and projects everything — symbol table, call graph, and (once L3/L4
exist) the complete cfg/cdg/ddg (L3) and the SDG param_*/summary (L4), i.e. the full CPG.
There is no "symbol-table-only" graph:
- A queryable graph is a whole-picture artifact — a partial graph silently answers queries wrongly ("no path from source to sink" when the dataflow edges were simply never projected).
- Incremental Bolt pushes need a stable node/edge vocabulary across runs; a graph whose shape depends on the flags of whichever run last touched it is unmergeable.
Consequently -a/--analysis-level and --graph-field-depth are JSON-path flags only. Passing
either together with --emit neo4j is an explicit non-zero error (never silently ignore a flag):
error: --analysis-level does not apply to --emit neo4j; the graph is always projected at full depth
--emit neo4j therefore inherits full L3+L4 cost once dataflow exists — by design; the cheap path is
--emit json at -a 1.
The reference analyzers converge on one shape — replicate it (references/analyzer-architecture.md):
neo4j/
project # PURE (IR, appName) -> GraphRows projection (walks the tree + all overlays). No I/O, no driver.
rows # output-agnostic data model + RowBuilder (in-memory MERGE, deferred edges)
cypher # snapshot writer -> graph.cypher (self-contained script)
bolt # incremental writer -> live Bolt push (lazy driver import)
schema # declarative schema: node labels, rel types, constraints, indexes, DDL
The seam that matters: project() is a pure function (IR, appName) → GraphRows with no I/O and
no driver — both writers consume the same rows identically.
Pure, deterministic, deduped. Property values are Neo4j-legal only (primitives + homogeneous
primitive arrays); null/absent are pruned (in Neo4j an absent property is null).
NodeRow { labels[], keyProp, value, props }—labels[0]is the constrained MERGE label; the rest are extra labels.NodeRef { label, keyProp, value }— how an edge addresses an endpoint to MATCH on.EdgeRow { type, from: NodeRef, to: NodeRef, props }.
RowBuilder accumulates with in-memory MERGE semantics and holds a deferred edge list: edges
to an external/library target are kept only if that target was actually emitted as a node this run
(gated in finish()). This is the graph-side "edge only when resolved" rule — CALLS/RESOLVES_TO/
EXTENDS/IMPLEMENTS never dangle; the unresolved-string fallback lives on the source node's props.
Self-contained and idempotent — running it (cypher-shell < graph.cypher) rebuilds this project's
subgraph from scratch. Order: (1) emit CONSTRAINTS then INDEXES (… IF NOT EXISTS); (2) scoped
wipe of this app's prior subgraph — MATCH (a:Application {name}) → owned modules/declarations →
DETACH DELETE (shared External/Package nodes are not wiped); (3) batched UNWIND … MERGE (n:Label {key}) SET n += row.p for nodes, 500 per batch; (4) batched UNWIND … MATCH … MERGE (a)-[r:TYPE]->(b) for edges.
Module-scoped diffing keyed on each module's content_hash: fetch the DB's per-module hashes, find
changed modules, and per module — in a transaction — delete edges owned by it, delete its
no-longer-emitted declarations, then MERGE current nodes/edges. Shared nodes are MERGE-only; full runs
orphan-prune modules whose source vanished. Batch ~1000/tx; import the driver lazily.
A declarative, versioned contract at the analyzer repo root, emitted by --emit schema — the
graph-side sibling of the keystone, what the SDK's Neo4j backend reconstructs against.
Load-bearing conventions:
- Dual-label / MERGE-label pattern. Type-like nodes (
Class/Interface/Enum/…) carry a shared merge label (Symbol) keyed on thecan://id, plus their specific label as an extra. This gives one uniqueness constraint per family and lets edges MATCH a target by a single(Symbol {id})lookup regardless of kind. _moduleprovenance on every project-owned node — the file that emitted it; the incremental writer uses it to isolate and delete exactly what a re-analyzed module previously wrote. Shared nodes (External/Package/Decorator) carry no_module.- Node families map straight from the schema decisions:
Application,Module, theSymboltypes,Callable,CallSite,Field,Parameter, plus the sharedPackage/External/Decorator. - Relationship types mirror containment + overlays:
HAS_MODULE,DECLARES,HAS_CALLABLE,HAS_FIELD,HAS_CALLSITE,RESOLVES_TO,CALLS(propsweight/prov),EXTENDS,IMPLEMENTS. - Relationship namespacing. Java prefixes (
J_HAS_UNIT,J_CALLS); Python usesPY_; a bare-namespaced new language uses unprefixed names. Pick one and hold it. Marker labels (Entrypoint) flag entrypoint nodes; omit if the analyzer has no entrypoint detection yet. - Add a
schema_versionand bump it additively as you extend (Python is at1.1.0over Java's1.0.0). The graph schema and JSON schema move in lockstep — two encodings of one contract.
When dataflow exists, the same GraphRows/writer machinery carries the CPG overlay (additive
schema.neo4j.json bump):
- Labels: body/CFG nodes (merge key = the
…@line:col/@tagid), propskind,start_line,end_line,_module. - Edge types:
CFG_NEXT(propkind),CDG,DDG(propsvar,prov),PARAM_IN,PARAM_OUT,SUMMARY, andHAS_CFG_NODE/HAS_BODY_NODE(Callable → node ownership). - The AST layer of the CPG is the existing symbol-table projection; the overlay is complete when a
Callable's body nodes, its
cdg/ddgedges, and the cross-function SDG edges are all present and the deferred-edge (no-dangling) gate holds.
--emit schemaproduces aschema.neo4j.jsonthat parses and lists every node family the JSON emits.--emit neo4jwith no--neo4j-uriwrites agraph.cypherthat runs clean against an empty Neo4j and is idempotent — running it twice yields the same graph (the scoped wipe guarantees this).- With
--neo4j-uri, a second run after editing one file touches only that module's subgraph. - No dangling relationships: every
EXTENDS/IMPLEMENTS/RESOLVES_TO/CALLS/PARAM_*/SUMMARYendpoint resolves to an emitted node. Node/edge counts at full depth match the JSON atmax_level(modulo the explicitHAS_*containment edges) — the cross-projection check inreferences/testing-and-validation.md.