The analyzer (built by codeanalyzer-backend) emits the canonical schema — one
additive node-tree with typed edge overlays (a CPG). The authoritative model is the
keystone skills/designing-cldk-changes/references/canonical-schema.md; design mode
owns it. This file states how the SDK encodes models over that shape while
keeping the public API identical. You do not redesign the schema here; you mirror it
and preserve the facade surface.
Always build and validate against a real sample
analysis.jsonfrom the analyzer (at eachmax_level), not against this summary or the keystone prose. The complete, authoritative field list is whatever the sample actually contains.
The schema is one additive node-tree + typed edge lists, identical across languages
(the parity clause: one Node, one Edge, one Application). The public API — the
CLDK.<lang>(...) factory, the <Lang>Analysis facade, the <Lang>AnalysisBackend
ABC, and every accessor's name, signature, and return type — must not move. You
hold both at once with a two-layer model: model the tree once, and re-express
the old per-language return types as thin views over it.
Migrating an existing language from an older schema is therefore a major SDK release (see the bottom section) — but a major version, not a breaking API: importers and callers are unchanged.
- Root is an envelope, not
Application.{ schema_version, language, max_level, k_limit?, analyzer{name,version}, application }.max_levelis authoritative — read it; never sniff for keys. application={ id: can://<lang>/<app>, kind, symbol_table{path→module}, call_graph[], param_in[], param_out[] }.symbol_tableandcall_graphlive insideapplication.- Named-map containment:
module → types{name→type} / functions{sig→callable};type → callables{sig→callable} / fields{name→field};callable → body{local-id→node}. - One
Nodeshape with akindstring discriminator (module|class|struct| interface|enum|function|method|statement|call|entry|formal_in|…) plusid,span(with byte offsets). Language flavor is akindvalue; language extras are additive optional fields (+ open-vocabtags{}). Absent = no fact (nonull). module.sourceholds the whole file's text once; every node's text is a byte-slice of it (module.source[node.span.bytes]) — there is no per-callablecodefield.- Call sites are
callnodes inbody{}with acalleeid that refinesnull → idat L2 — not acall_sites[]array ofCallsiteobjects (get_call_sitesis an L1 accessor). - Edges are
{ src, dst, …attrs }referencing node ids, in lists keyed by the list name (the list name is the type — notype/CALL_DEPfield, noCallEdgemodel). Intra-callable overlays (cfg/cdg/ddg/summary) hang on the callable; cross-callable (call_graph/param_in/param_out) on the application.provon addgedge is["ssa"]=syntactic (L3) vs["points-to"]=semantic (L4). - The join key is the
id(acan://URI), notsignature.signaturesurvives as the callable's human-readable field (== the id's last path segment); the facade maps signature→id at the boundary.
Durable ids (≥ callable) are a containment path with an application segment so multiple apps in one language don't collide:
can://<lang>/<app>/<file>/<type>/<callable-signature>
can://go/myapp/src/util.go/Hasher/Hash(string)uint64
Ordinal ids (< callable) address statements/synthetic vertices within a callable:
<callable-id>@<line>:<col> e.g. …/Hash(string)uint64@15:2 (a statement)
<callable-id>@<tag> e.g. …/Hash(string)uint64@entry (synthetic vertex)
…@formal_in:0, …@16:2/actual_in:0
The / @ : delimiters are chosen not to collide with the durable-symbol grammar. Keep
this grammar in lockstep with the keystone's § Identity and the upstream cldk:// RFC.
New shared package cldk/models/cpg/, validating the schema for every language:
class Span(_NullSafeBase):
start: Tuple[int, int]; end: Tuple[int, int]; bytes: Tuple[int, int] # [from,to] → O(1) slice
class Node(_NullSafeBase):
id: str; kind: str; span: Optional[Span] = None; parent: Optional[str] = None
# type-node: base_types[], interfaces[], modifiers[], decorators[], callables{}, fields{}
# callable-node: signature, parameters[], return_type, error_channel[], metrics{}, refs{},
# body{local-id→Node}, cfg[], cdg[], ddg[], summary[]
# body-node: callee (null→id refinement), arguments[], of
# language extras: additive Optional fields + tags: Dict[str,str]
class Edge(_NullSafeBase):
src: str; dst: str; kind: Optional[str] = None; var: Optional[str] = None
prov: List[str] = []; weight: int = 1
class Module(_NullSafeBase):
id: str; kind: Literal["module"]="module"; package: Optional[str]=None; source: str=""
imports: List[Import]=[]; types: Dict[str,Node]={}; functions: Dict[str,Node]={}; content_hash: Optional[str]=None
class Application(_NullSafeBase):
id: str; kind: Literal["application"]="application"
symbol_table: Dict[str, Module]; call_graph: List[Edge]=[]; param_in: List[Edge]=[]; param_out: List[Edge]=[]
class Analyzer(_NullSafeBase):
name: str; version: str
class AnalysisPayload(_NullSafeBase): # the envelope / manifest — .application is the tree
schema_version: str; language: str; max_level: int; k_limit: Optional[int]=None
analyzer: Optional[Analyzer]=None; application: ApplicationPrefer a single Node with a string kind over per-language subclasses or a rigid
Literal union: an open-vocab kind loads kinds added later; a rigid hierarchy can't.
Keep _NullSafeBase on every shared model — Go/Rust/C still serialize empty
collections as null (mechanics in python-sdk-wiring.md § Common pitfalls). Note this
cpg/ package is the schema-v2 target; the SDK on disk may still carry per-language v1
trees until the first language migrates and builds it — verify per SDK.
The old return types become thin (node, module) wrappers exposing the old field
names as @property/@computed_field, exported under the old names so every import
path and attribute surface is unchanged:
class CallableView:
def __init__(self, node, module): self._n, self._m = node, module
@property
def signature(self): return self._n.signature
@property
def code(self): # was a stored field; now a slice
f, t = self._n.span.bytes; return self._m.source[f:t]
@property
def call_sites(self): # was a field; now body 'call' nodes
return [CallsiteView(c, self._m) for c in self._n.body.values() if c.kind == "call"]
class ModuleView:
@property
def classes(self): return {k:TypeView(v,self._m) for k,v in self._m.types.items() if v.kind=="class"}
@property
def interfaces(self): return {k:TypeView(v,self._m) for k,v in self._m.types.items() if v.kind=="interface"}| Old per-language name | New backing | What it becomes |
|---|---|---|
<L>Callable |
CallableView(node, module) |
same .signature/.parameters/.code/.call_sites — .code is a source slice, .call_sites are body call nodes |
<L>Class / <L>Type |
TypeView(node, module) |
.methods/.attributes = kind-filters over one callables{}/fields{} |
<L>Module |
ModuleView(node, module) |
.classes/.interfaces/.enums = kind-filters over one types{} |
<L>Callsite |
CallsiteView(node, module) |
{callee, arguments, span} off a call node — no CallEdge/rich-edge model |
<L>Application |
Application |
the envelope's .application tree directly |
Per-language __init__.py shrinks to aliases + language-kind/field registration:
# cldk/models/<lang>/__init__.py — no per-language schema tree anymore
from cldk.models.cpg.views import CallableView, TypeView, ModuleView, CallsiteView
from cldk.models.cpg.models import Application
<L>Callable, <L>Class, <L>Module, <L>Callsite, <L>Application = \
CallableView, TypeView, ModuleView, CallsiteView, ApplicationAll "keep the same public shape" work concentrates in this small, shared view layer.
The current entry API is a per-language factory (CLDK.<lang>(project_path=..., backend=...)); the older CLDK(language="<lang>").analysis(project_path=...) stays
wired as a compat shim that forwards to the factory. Keep it working — it is part of
the frozen surface — but the factory methods are canonical (python-sdk-wiring.md § 4).
The API surface is preserved, but these semantics do shift — document each:
nx.DiGraphnode keys: signature →can://id.get_call_graph()/get_class_hierarchy()nodes were signature strings; now they key by durable id (signatures aren't globally unique). Attachsignatureas a node attribute and offer a signature→id resolver, but the node key type changes..code/get_method_body()is a computed slice, identical whenmodule.sourceis present — so the Neo4j backend is lossy for body text ifsourcewasn't projected (neo4j-backend.md). Parity is "same model modulo documented lossiness."- Call-site payload thins. The
callnode carries{callee, arguments, span}; richer fields (receiver_type,argument_types) exist only if the analyzer added them additively.get_call_sites()keeps its return type; some fields may beNone. - Rich call edges retire. Edges are identity-only
{src,dst,prov,weight}; the old detail-on-the-edge is gone.get_callers/get_calleesstill return detail, but it is reconstructed by id-join; call-site-level linking lives in L4param_*. external_symbols/ phantom nodes change. "No dangling endpoints" + "edge only when resolved" means externals are materialized as stub nodes or the edge is omitted, not parked in a separate map. Re-express reachability that relied on phantom nodes.get_call_graph_json()/model_dump_json()content changes (envelope keys) — return typestris stable; consumers parsing the string by old keys break.AnalysisLevelgains L1–L4 (bodybegins at L1 with call sites, completes at L3); the level→flag mapping must be re-derived frommax_level.
Migrating a language's model layer to a new schema major is a major SDK version bump
because: (a) the analyzer dependency is major-bumped and the pin moves in lockstep
across every SDK; (b) the seven documented semantic shifts above can break consumers who
reached past the public API into node-key types or JSON string internals. It is not
a breaking change to the public API itself — the Iron Rule holds: names, signatures, and
return types are frozen behind the view layer. Cut the SDK release only after the
analyzer release is cut (skills/designing-cldk-changes/references/schema-migration.md).
Mechanics of encoding all this: python-sdk-wiring.md (Python), typescript-sdk-wiring.md
(TS), neo4j-backend.md (the graph backend), sdk-testing.md (fixtures + gates).