Skip to content

Commit 46dfd5a

Browse files
authored
feat(models): cpg — canonical schema-v2 models, modeled once (F2 / #240) (#274)
* feat(cpg): _NullSafeBase + Span (#240) * feat(cpg): Edge + Import leaf models (#240) * test(cpg): cover Edge empty-prov default and Import.span default (#240) * feat(cpg): open-kind Node covering type/callable/body facets (#240) * feat(cpg): Module/Application/Analyzer/AnalysisPayload + exports (#240) * test(cpg): cover Application->Module->Node deep composition (#240) * test(cpg): parse real L1/L4 samples from both analyzers + superset gate (#240) * fix(cpg): enforce id on durable nodes positionally; body nodes exempt (#240) * test(cpg): pin the F7/F3 accessor contract against real L4 samples (#240) * test(cpg): genuinely pin module.functions accessor; strengthen TS source check (#240) * fix(cpg): add Module.span so the common span field parses (#240) span is listed as a common field on every node in the keystone (Part II), module included, but Module had no span field so it degraded to a raw dict in model_extra instead of parsing as Span. The ts-a4/ts-a1 fixtures emit span on the module node. * test(cpg): pin cdg/summary/k_limit/TS body+cfg in the accessor contract (#240) extra="allow" absorbs unknown keys, so deleting a canonical field leaves every parse test green — the accessor contract is the only guard. cdg and summary (both in F7's cfg/cdg/ddg/summary read set), the envelope k_limit, and TS callable body/cfg were unpinned. Each new assertion dereferences an element/field rather than doing a bare isinstance check, since a raw dict-of-dicts under extra="allow" still satisfies isinstance(list)/isinstance(dict) — confirmed by temporarily removing each field from the model and watching the new asserts fail before restoring.
1 parent b7b56f6 commit 46dfd5a

14 files changed

Lines changed: 414 additions & 0 deletions

cldk/models/cpg/__init__.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
from cldk.models.cpg.base import _NullSafeBase
2+
from cldk.models.cpg.models import (
3+
Span, Import, Edge, Node, Module, Application, Analyzer, AnalysisPayload,
4+
)
5+
6+
__all__ = [
7+
"AnalysisPayload", "Application", "Module", "Node", "Edge", "Span", "Import", "Analyzer",
8+
]

cldk/models/cpg/base.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
from __future__ import annotations
2+
from pydantic import BaseModel, ConfigDict, model_validator
3+
4+
5+
class _NullSafeBase(BaseModel):
6+
"""Shared base for every canonical (cpg) model. `extra="allow"` so language-specific fields
7+
(TS is_tsx/exports, Python package, …) are tolerated and preserved rather than rejected — the
8+
device that lets ONE model set parse every analyzer. The before-validator drops None-valued
9+
keys so a collection serialized as `null` (Go/Rust/C) falls back to its field default; the one
10+
sanctioned null (a body-node `callee`) simply resolves to its `None` default."""
11+
12+
model_config = ConfigDict(extra="allow")
13+
14+
@model_validator(mode="before")
15+
@classmethod
16+
def _drop_nulls(cls, data):
17+
if isinstance(data, dict):
18+
return {k: v for k, v in data.items() if v is not None}
19+
return data

cldk/models/cpg/models.py

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
from __future__ import annotations
2+
from typing import Any, Dict, List, Optional, Tuple
3+
from pydantic import model_validator
4+
from cldk.models.cpg.base import _NullSafeBase
5+
6+
7+
class Span(_NullSafeBase):
8+
start: Tuple[int, int]
9+
end: Tuple[int, int]
10+
bytes: Tuple[int, int]
11+
12+
13+
class Edge(_NullSafeBase):
14+
src: str
15+
dst: str
16+
kind: Optional[str] = None # cfg edge kind; absent on identity edges
17+
var: Optional[str] = None # ddg access path
18+
prov: List[str] = [] # ["jedi"|"pycg"|"tsc"|"jelly"] (call) / ["ssa"|"points-to"] (ddg)
19+
weight: int = 1
20+
21+
22+
class Import(_NullSafeBase):
23+
name: str
24+
path: Optional[str] = None
25+
alias: Optional[str] = None
26+
span: Optional[Span] = None
27+
28+
29+
class Node(_NullSafeBase):
30+
# Optional because sub-callable body nodes are keyed by local position and omit id; durable
31+
# nodes (types/callables/functions/fields) are required to carry it — enforced positionally by
32+
# the container validators.
33+
id: Optional[str] = None
34+
kind: str
35+
span: Optional[Span] = None
36+
parent: Optional[str] = None
37+
# type facet
38+
base_types: List[str] = []
39+
interfaces: List[str] = []
40+
modifiers: List[str] = []
41+
decorators: List[Any] = []
42+
callables: Dict[str, "Node"] = {}
43+
fields: Dict[str, "Node"] = {}
44+
# callable facet
45+
signature: Optional[str] = None
46+
parameters: List[Any] = []
47+
return_type: Optional[str] = None
48+
error_channel: List[str] = []
49+
metrics: Dict[str, Any] = {}
50+
refs: Dict[str, Any] = {}
51+
body: Dict[str, "Node"] = {}
52+
cfg: List[Edge] = []
53+
cdg: List[Edge] = []
54+
ddg: List[Edge] = []
55+
summary: List[Edge] = []
56+
# field / body-node facet
57+
type: Optional[str] = None
58+
callee: Optional[str] = None
59+
arguments: List[str] = []
60+
of: Optional[str] = None
61+
# open vocab
62+
tags: Dict[str, str] = {}
63+
64+
@model_validator(mode="after")
65+
def _durable_children_require_id(self):
66+
for container in (self.callables, self.fields):
67+
for key, node in container.items():
68+
if node.id is None:
69+
raise ValueError(f"durable node {key!r} under {self.id or '<body>'!r} is missing required id")
70+
return self
71+
72+
73+
class Module(_NullSafeBase):
74+
id: str
75+
kind: str = "module"
76+
span: Optional[Span] = None
77+
package: Optional[str] = None
78+
source: str = ""
79+
imports: List[Import] = []
80+
types: Dict[str, Node] = {}
81+
functions: Dict[str, Node] = {}
82+
content_hash: Optional[str] = None
83+
84+
@model_validator(mode="after")
85+
def _durable_children_require_id(self):
86+
for container in (self.types, self.functions):
87+
for key, node in container.items():
88+
if node.id is None:
89+
raise ValueError(f"durable node {key!r} in module {self.id!r} is missing required id")
90+
return self
91+
92+
93+
class Application(_NullSafeBase):
94+
id: str
95+
kind: str = "application"
96+
symbol_table: Dict[str, Module] = {}
97+
call_graph: List[Edge] = []
98+
param_in: List[Edge] = []
99+
param_out: List[Edge] = []
100+
101+
102+
class Analyzer(_NullSafeBase):
103+
name: str
104+
version: Optional[str] = None
105+
106+
107+
class AnalysisPayload(_NullSafeBase):
108+
schema_version: str
109+
language: str
110+
max_level: int
111+
k_limit: Optional[int] = None
112+
analyzer: Optional[Analyzer] = None
113+
application: Application

tests/models/cpg/__init__.py

Whitespace-only changes.
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
"""Pin the accessors F7 (Task-7 provider) and F3 (views) depend on, against a real L4 sample."""
2+
import json
3+
from pathlib import Path
4+
from cldk.models.cpg import AnalysisPayload
5+
6+
RES = Path(__file__).parent.parent.parent / "resources" / "cpg"
7+
8+
9+
def _app(name):
10+
return AnalysisPayload(**json.loads((RES / name).read_text())).application
11+
12+
13+
def test_symbol_table_module_source_and_containment():
14+
app = _app("py-a4.json")
15+
mod = app.symbol_table["pkg/mod.py"]
16+
assert isinstance(mod.source, str) and mod.source # module.source (byte-slice base)
17+
assert isinstance(mod.types, dict) and isinstance(mod.functions, dict) # both accessors pinned
18+
assert mod.types or mod.functions # at least one populated
19+
20+
21+
def test_callable_body_and_dataflow_edges_present_at_l4():
22+
app = _app("py-a4.json")
23+
mod = app.symbol_table["pkg/mod.py"]
24+
cls = next(iter(mod.types.values()))
25+
call = next(iter(cls.callables.values()))
26+
assert call.signature # callable.signature
27+
assert call.body # body{} populated at L4
28+
assert call.cfg and call.ddg # cfg/ddg edge lists
29+
# cdg/summary are unpinned elsewhere and are the sole extra="allow" guard for these two
30+
# fields (both in F7's cfg/cdg/ddg/summary read set) — dereference an element attribute so a
31+
# deleted field (which would fall back to a raw dict under extra="allow") fails loudly.
32+
assert isinstance(call.cdg, list) and isinstance(call.summary, list)
33+
assert call.cdg[0].src and call.summary[0].src
34+
# span.bytes present for slicing a body node
35+
some = next(iter(call.body.values()))
36+
assert some.span is None or (some.span.bytes and len(some.span.bytes) == 2)
37+
38+
39+
def test_envelope_k_limit_at_l4():
40+
payload = AnalysisPayload(**json.loads((RES / "py-a4.json").read_text()))
41+
assert payload.k_limit == 3
42+
# a plain value check alone would still pass via the extra="allow" passthrough even if
43+
# k_limit were deleted from the model — assert it's a declared field, not an extras leak.
44+
assert "k_limit" not in (payload.model_extra or {})
45+
46+
47+
def test_application_interprocedural_edges_at_l4():
48+
app = _app("py-a4.json")
49+
assert app.call_graph # L2 call graph
50+
assert app.param_in and app.param_out # L4 SDG param edges
51+
for e in app.call_graph:
52+
assert e.src.startswith("can://") and e.dst.startswith("can://") # can:// identity
53+
54+
55+
def test_typescript_sample_same_accessors():
56+
app = _app("ts-a4.json")
57+
mod = next(iter(app.symbol_table.values()))
58+
assert isinstance(mod.source, str) and mod.source
59+
# a TS type node with callables
60+
typ = next((t for t in mod.types.values() if t.callables), None)
61+
assert typ is not None and next(iter(typ.callables.values())).signature is not None
62+
# resetPassword under type Users: body/cfg must resolve to parsed Node/Edge, not raw dicts,
63+
# so the TS analyzer path isn't pinned on source/signature alone.
64+
call = typ.callables["resetPassword"]
65+
assert isinstance(call.body, dict) and next(iter(call.body.values())).kind
66+
assert isinstance(call.cfg, list) and call.cfg[0].kind

tests/models/cpg/test_base_span.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
from cldk.models.cpg.models import Span
2+
from cldk.models.cpg.base import _NullSafeBase
3+
from pydantic import ConfigDict
4+
from typing import Dict, List
5+
6+
7+
class _M(_NullSafeBase):
8+
xs: List[int] = []
9+
d: Dict[str, int] = {}
10+
opt: int | None = None
11+
12+
13+
def test_null_collections_coerce_to_defaults():
14+
m = _M(**{"xs": None, "d": None, "opt": None})
15+
assert m.xs == [] and m.d == {} and m.opt is None
16+
17+
18+
def test_extra_fields_are_allowed_and_preserved():
19+
m = _M(**{"xs": [1], "is_tsx": True}) # language-specific extra
20+
assert m.xs == [1]
21+
assert m.model_extra.get("is_tsx") is True
22+
23+
24+
def test_span_parses_byte_offsets():
25+
s = Span(**{"start": [1, 0], "end": [4, 2], "bytes": [0, 40]})
26+
assert s.start == (1, 0) and s.end == (4, 2) and s.bytes == (0, 40)
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import pytest
2+
from pydantic import ValidationError
3+
4+
from cldk.models.cpg import AnalysisPayload, Application, Module, Node, Edge, Span, Import, Analyzer
5+
6+
7+
def test_envelope_reads_authoritative_level():
8+
p = AnalysisPayload(**{
9+
"schema_version": "2.0.0", "language": "python", "max_level": 4, "k_limit": 3,
10+
"analyzer": {"name": "codeanalyzer-python", "version": "0.4.0"},
11+
"application": {"id": "can://python/app", "kind": "application", "symbol_table": {}},
12+
})
13+
assert p.schema_version == "2.0.0" and p.max_level == 4 and p.k_limit == 3
14+
assert p.analyzer.name == "codeanalyzer-python"
15+
assert p.application.id == "can://python/app"
16+
17+
18+
def test_module_holds_source_and_containment():
19+
m = Module(**{"id": "can://python/app/m.py", "kind": "module", "source": "x = 1\n",
20+
"types": {"C": {"id": "can://python/app/m.py/C", "kind": "class"}},
21+
"functions": {"f()": {"id": "can://python/app/m.py/f()", "kind": "function"}}})
22+
assert m.source == "x = 1\n"
23+
assert m.types["C"].kind == "class" and m.functions["f()"].kind == "function"
24+
25+
26+
def test_module_durable_node_missing_id_raises():
27+
# a type reached through the durable-containment dict MUST carry the join key id
28+
with pytest.raises(ValidationError):
29+
Module(**{"id": "m", "types": {"C": {"kind": "class"}}}) # no id on the type
30+
31+
32+
def test_application_edge_lists():
33+
a = Application(**{"id": "can://python/app", "kind": "application", "symbol_table": {},
34+
"call_graph": [{"src": "a", "dst": "b", "prov": ["jedi"], "weight": 1}],
35+
"param_in": [{"src": "c@in", "dst": "d@in"}]})
36+
assert a.call_graph[0].dst == "b" and a.param_in[0].src == "c@in" and a.param_out == []
37+
38+
39+
def test_symbol_table_deep_composition():
40+
from cldk.models.cpg import Application, Module, Node
41+
a = Application(**{
42+
"id": "can://python/app", "kind": "application",
43+
"symbol_table": {
44+
"pkg/m.py": {
45+
"id": "can://python/app/pkg/m.py", "kind": "module", "source": "x = 1\n",
46+
"types": {"C": {"id": "can://python/app/pkg/m.py/C", "kind": "class",
47+
"callables": {"C.f()": {"id": "can://python/app/pkg/m.py/C/f()",
48+
"kind": "method", "signature": "f"}}}},
49+
"functions": {"g()": {"id": "can://python/app/pkg/m.py/g()", "kind": "function"}},
50+
}
51+
},
52+
})
53+
mod = a.symbol_table["pkg/m.py"]
54+
assert isinstance(mod, Module) and mod.source == "x = 1\n"
55+
cls = mod.types["C"]
56+
assert isinstance(cls, Node) and cls.kind == "class"
57+
method = cls.callables["C.f()"]
58+
assert isinstance(method, Node) and method.kind == "method" and method.signature == "f"
59+
assert isinstance(mod.functions["g()"], Node)
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
from cldk.models.cpg.models import Edge, Import
2+
3+
4+
def test_call_edge_shape():
5+
e = Edge(**{"src": "can://p/a#f", "dst": "can://p/a#g", "prov": ["jedi", "pycg"], "weight": 2})
6+
assert e.src.endswith("#f") and e.dst.endswith("#g")
7+
assert e.prov == ["jedi", "pycg"] and e.weight == 2 and e.kind is None and e.var is None
8+
9+
10+
def test_ddg_edge_carries_var_and_prov():
11+
e = Edge(**{"src": "a@1:0", "dst": "a@2:0", "var": "x", "prov": ["ssa"]})
12+
assert e.var == "x" and e.prov == ["ssa"] and e.weight == 1
13+
14+
15+
def test_edge_empty_prov_and_weight_defaults():
16+
e = Edge(src="a", dst="b")
17+
assert e.prov == [] and e.weight == 1
18+
19+
20+
def test_import_optional_fields():
21+
i = Import(**{"name": "os"})
22+
assert i.name == "os" and i.path is None and i.alias is None and i.span is None

tests/models/cpg/test_node.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import pytest
2+
from pydantic import ValidationError
3+
4+
from cldk.models.cpg.models import Node
5+
6+
7+
def test_class_node_facet():
8+
n = Node(**{"id": "can://p/m.py/C", "kind": "class",
9+
"callables": {"C.f()": {"id": "can://p/m.py/C/f()", "kind": "method", "signature": "f"}}})
10+
assert n.kind == "class"
11+
assert n.callables["C.f()"].kind == "method" and n.callables["C.f()"].signature == "f"
12+
13+
14+
def test_callable_node_carries_body_and_edges():
15+
n = Node(**{"id": "can://p/m.py/f()", "kind": "function", "signature": "f()",
16+
"body": {"f@1:0": {"id": "can://p/m.py/f()@1:0", "kind": "statement"}},
17+
"cfg": [{"src": "can://p/m.py/f()@1:0", "dst": "can://p/m.py/f()@2:0", "kind": "fallthrough"}],
18+
"ddg": [{"src": "can://p/m.py/f()@1:0", "dst": "can://p/m.py/f()@2:0", "var": "x", "prov": ["ssa"]}]})
19+
assert set(n.body) == {"f@1:0"}
20+
assert n.cfg[0].kind == "fallthrough" and n.ddg[0].var == "x" and n.ddg[0].prov == ["ssa"]
21+
22+
23+
def test_call_body_node_callee_refines_from_null():
24+
n = Node(**{"id": "a@2:0", "kind": "call", "callee": None, "arguments": ["a@2:0/arg0"]})
25+
assert n.kind == "call" and n.callee is None and n.arguments == ["a@2:0/arg0"]
26+
27+
28+
def test_language_extra_field_preserved():
29+
n = Node(**{"id": "x", "kind": "class", "is_abstract": True}) # a language-specific flag
30+
assert n.model_extra.get("is_abstract") is True
31+
32+
33+
def test_durable_callable_missing_id_raises():
34+
# a callable reached through the durable-containment dict MUST carry the join key id
35+
with pytest.raises(ValidationError):
36+
Node(**{"id": "can://p/m.py/C", "kind": "class",
37+
"callables": {"C.f()": {"kind": "method"}}}) # no id on the callable
38+
39+
40+
def test_body_node_missing_id_parses():
41+
# body nodes are keyed by local position and legitimately omit id — must NOT raise
42+
n = Node(**{"id": "can://p/m.py/f()", "kind": "function",
43+
"body": {"1:0": {"kind": "statement"}}})
44+
assert n.body["1:0"].id is None and n.body["1:0"].kind == "statement"

0 commit comments

Comments
 (0)