From 7c2c015878d33109cdb1bcb5d2fff43381eb03b7 Mon Sep 17 00:00:00 2001 From: Rahul Krishna Date: Tue, 14 Jul 2026 21:12:17 -0400 Subject: [PATCH 01/12] feat(cpg): _NullSafeBase + Span (#240) --- cldk/models/cpg/__init__.py | 0 cldk/models/cpg/base.py | 19 +++++++++++++++++++ cldk/models/cpg/models.py | 9 +++++++++ tests/models/cpg/__init__.py | 0 tests/models/cpg/test_base_span.py | 26 ++++++++++++++++++++++++++ 5 files changed, 54 insertions(+) create mode 100644 cldk/models/cpg/__init__.py create mode 100644 cldk/models/cpg/base.py create mode 100644 cldk/models/cpg/models.py create mode 100644 tests/models/cpg/__init__.py create mode 100644 tests/models/cpg/test_base_span.py diff --git a/cldk/models/cpg/__init__.py b/cldk/models/cpg/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/cldk/models/cpg/base.py b/cldk/models/cpg/base.py new file mode 100644 index 00000000..ad9df709 --- /dev/null +++ b/cldk/models/cpg/base.py @@ -0,0 +1,19 @@ +from __future__ import annotations +from pydantic import BaseModel, ConfigDict, model_validator + + +class _NullSafeBase(BaseModel): + """Shared base for every canonical (cpg) model. `extra="allow"` so language-specific fields + (TS is_tsx/exports, Python package, …) are tolerated and preserved rather than rejected — the + device that lets ONE model set parse every analyzer. The before-validator drops None-valued + keys so a collection serialized as `null` (Go/Rust/C) falls back to its field default; the one + sanctioned null (a body-node `callee`) simply resolves to its `None` default.""" + + model_config = ConfigDict(extra="allow") + + @model_validator(mode="before") + @classmethod + def _drop_nulls(cls, data): + if isinstance(data, dict): + return {k: v for k, v in data.items() if v is not None} + return data diff --git a/cldk/models/cpg/models.py b/cldk/models/cpg/models.py new file mode 100644 index 00000000..6647f323 --- /dev/null +++ b/cldk/models/cpg/models.py @@ -0,0 +1,9 @@ +from __future__ import annotations +from typing import Tuple +from cldk.models.cpg.base import _NullSafeBase + + +class Span(_NullSafeBase): + start: Tuple[int, int] + end: Tuple[int, int] + bytes: Tuple[int, int] diff --git a/tests/models/cpg/__init__.py b/tests/models/cpg/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/models/cpg/test_base_span.py b/tests/models/cpg/test_base_span.py new file mode 100644 index 00000000..ad9df5a1 --- /dev/null +++ b/tests/models/cpg/test_base_span.py @@ -0,0 +1,26 @@ +from cldk.models.cpg.models import Span +from cldk.models.cpg.base import _NullSafeBase +from pydantic import ConfigDict +from typing import Dict, List + + +class _M(_NullSafeBase): + xs: List[int] = [] + d: Dict[str, int] = {} + opt: int | None = None + + +def test_null_collections_coerce_to_defaults(): + m = _M(**{"xs": None, "d": None, "opt": None}) + assert m.xs == [] and m.d == {} and m.opt is None + + +def test_extra_fields_are_allowed_and_preserved(): + m = _M(**{"xs": [1], "is_tsx": True}) # language-specific extra + assert m.xs == [1] + assert m.model_extra.get("is_tsx") is True + + +def test_span_parses_byte_offsets(): + s = Span(**{"start": [1, 0], "end": [4, 2], "bytes": [0, 40]}) + assert s.start == (1, 0) and s.end == (4, 2) and s.bytes == (0, 40) From 64b81fc013d0f0e75926fdac7c47c59f1be33f87 Mon Sep 17 00:00:00 2001 From: Rahul Krishna Date: Tue, 14 Jul 2026 21:19:06 -0400 Subject: [PATCH 02/12] feat(cpg): Edge + Import leaf models (#240) --- cldk/models/cpg/models.py | 18 +++++++++++++++++- tests/models/cpg/test_edge_import.py | 17 +++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) create mode 100644 tests/models/cpg/test_edge_import.py diff --git a/cldk/models/cpg/models.py b/cldk/models/cpg/models.py index 6647f323..c71870ef 100644 --- a/cldk/models/cpg/models.py +++ b/cldk/models/cpg/models.py @@ -1,5 +1,5 @@ from __future__ import annotations -from typing import Tuple +from typing import List, Optional, Tuple from cldk.models.cpg.base import _NullSafeBase @@ -7,3 +7,19 @@ class Span(_NullSafeBase): start: Tuple[int, int] end: Tuple[int, int] bytes: Tuple[int, int] + + +class Edge(_NullSafeBase): + src: str + dst: str + kind: Optional[str] = None # cfg edge kind; absent on identity edges + var: Optional[str] = None # ddg access path + prov: List[str] = [] # ["jedi"|"pycg"|"tsc"|"jelly"] (call) / ["ssa"|"points-to"] (ddg) + weight: int = 1 + + +class Import(_NullSafeBase): + name: str + path: Optional[str] = None + alias: Optional[str] = None + span: Optional[Span] = None diff --git a/tests/models/cpg/test_edge_import.py b/tests/models/cpg/test_edge_import.py new file mode 100644 index 00000000..e594f4ae --- /dev/null +++ b/tests/models/cpg/test_edge_import.py @@ -0,0 +1,17 @@ +from cldk.models.cpg.models import Edge, Import + + +def test_call_edge_shape(): + e = Edge(**{"src": "can://p/a#f", "dst": "can://p/a#g", "prov": ["jedi", "pycg"], "weight": 2}) + assert e.src.endswith("#f") and e.dst.endswith("#g") + assert e.prov == ["jedi", "pycg"] and e.weight == 2 and e.kind is None and e.var is None + + +def test_ddg_edge_carries_var_and_prov(): + e = Edge(**{"src": "a@1:0", "dst": "a@2:0", "var": "x", "prov": ["ssa"]}) + assert e.var == "x" and e.prov == ["ssa"] and e.weight == 1 + + +def test_import_optional_fields(): + i = Import(**{"name": "os"}) + assert i.name == "os" and i.path is None and i.alias is None From b55ceaa5fc7d1551c26c3187dcc8dcb36f29bbb0 Mon Sep 17 00:00:00 2001 From: Rahul Krishna Date: Tue, 14 Jul 2026 21:25:49 -0400 Subject: [PATCH 03/12] test(cpg): cover Edge empty-prov default and Import.span default (#240) --- tests/models/cpg/test_edge_import.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/models/cpg/test_edge_import.py b/tests/models/cpg/test_edge_import.py index e594f4ae..42067bbf 100644 --- a/tests/models/cpg/test_edge_import.py +++ b/tests/models/cpg/test_edge_import.py @@ -12,6 +12,11 @@ def test_ddg_edge_carries_var_and_prov(): assert e.var == "x" and e.prov == ["ssa"] and e.weight == 1 +def test_edge_empty_prov_and_weight_defaults(): + e = Edge(src="a", dst="b") + assert e.prov == [] and e.weight == 1 + + def test_import_optional_fields(): i = Import(**{"name": "os"}) - assert i.name == "os" and i.path is None and i.alias is None + assert i.name == "os" and i.path is None and i.alias is None and i.span is None From 04e65ad8b67aba6e45e37eb2aeeda7e906a26a64 Mon Sep 17 00:00:00 2001 From: Rahul Krishna Date: Tue, 14 Jul 2026 21:27:49 -0400 Subject: [PATCH 04/12] feat(cpg): open-kind Node covering type/callable/body facets (#240) --- cldk/models/cpg/models.py | 35 ++++++++++++++++++++++++++++++++++- tests/models/cpg/test_node.py | 27 +++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 1 deletion(-) create mode 100644 tests/models/cpg/test_node.py diff --git a/cldk/models/cpg/models.py b/cldk/models/cpg/models.py index c71870ef..50d9ef94 100644 --- a/cldk/models/cpg/models.py +++ b/cldk/models/cpg/models.py @@ -1,5 +1,5 @@ from __future__ import annotations -from typing import List, Optional, Tuple +from typing import Any, Dict, List, Optional, Tuple from cldk.models.cpg.base import _NullSafeBase @@ -23,3 +23,36 @@ class Import(_NullSafeBase): path: Optional[str] = None alias: Optional[str] = None span: Optional[Span] = None + + +class Node(_NullSafeBase): + id: str + kind: str + span: Optional[Span] = None + parent: Optional[str] = None + # type facet + base_types: List[str] = [] + interfaces: List[str] = [] + modifiers: List[str] = [] + decorators: List[Any] = [] + callables: Dict[str, "Node"] = {} + fields: Dict[str, "Node"] = {} + # callable facet + signature: Optional[str] = None + parameters: List[Any] = [] + return_type: Optional[str] = None + error_channel: List[str] = [] + metrics: Dict[str, Any] = {} + refs: Dict[str, Any] = {} + body: Dict[str, "Node"] = {} + cfg: List[Edge] = [] + cdg: List[Edge] = [] + ddg: List[Edge] = [] + summary: List[Edge] = [] + # field / body-node facet + type: Optional[str] = None + callee: Optional[str] = None + arguments: List[str] = [] + of: Optional[str] = None + # open vocab + tags: Dict[str, str] = {} diff --git a/tests/models/cpg/test_node.py b/tests/models/cpg/test_node.py new file mode 100644 index 00000000..cc2f749c --- /dev/null +++ b/tests/models/cpg/test_node.py @@ -0,0 +1,27 @@ +from cldk.models.cpg.models import Node + + +def test_class_node_facet(): + n = Node(**{"id": "can://p/m.py/C", "kind": "class", + "callables": {"C.f()": {"id": "can://p/m.py/C/f()", "kind": "method", "signature": "f"}}}) + assert n.kind == "class" + assert n.callables["C.f()"].kind == "method" and n.callables["C.f()"].signature == "f" + + +def test_callable_node_carries_body_and_edges(): + n = Node(**{"id": "can://p/m.py/f()", "kind": "function", "signature": "f()", + "body": {"f@1:0": {"id": "can://p/m.py/f()@1:0", "kind": "statement"}}, + "cfg": [{"src": "can://p/m.py/f()@1:0", "dst": "can://p/m.py/f()@2:0", "kind": "fallthrough"}], + "ddg": [{"src": "can://p/m.py/f()@1:0", "dst": "can://p/m.py/f()@2:0", "var": "x", "prov": ["ssa"]}]}) + assert set(n.body) == {"f@1:0"} + assert n.cfg[0].kind == "fallthrough" and n.ddg[0].var == "x" and n.ddg[0].prov == ["ssa"] + + +def test_call_body_node_callee_refines_from_null(): + n = Node(**{"id": "a@2:0", "kind": "call", "callee": None, "arguments": ["a@2:0/arg0"]}) + assert n.kind == "call" and n.callee is None and n.arguments == ["a@2:0/arg0"] + + +def test_language_extra_field_preserved(): + n = Node(**{"id": "x", "kind": "class", "is_abstract": True}) # a language-specific flag + assert n.model_extra.get("is_abstract") is True From f4d754e2b5c43614c7bf896aec1af30be3b5d556 Mon Sep 17 00:00:00 2001 From: Rahul Krishna Date: Tue, 14 Jul 2026 21:33:45 -0400 Subject: [PATCH 05/12] feat(cpg): Module/Application/Analyzer/AnalysisPayload + exports (#240) --- cldk/models/cpg/__init__.py | 8 +++++++ cldk/models/cpg/models.py | 34 +++++++++++++++++++++++++++++ tests/models/cpg/test_containers.py | 27 +++++++++++++++++++++++ 3 files changed, 69 insertions(+) create mode 100644 tests/models/cpg/test_containers.py diff --git a/cldk/models/cpg/__init__.py b/cldk/models/cpg/__init__.py index e69de29b..8efbe01f 100644 --- a/cldk/models/cpg/__init__.py +++ b/cldk/models/cpg/__init__.py @@ -0,0 +1,8 @@ +from cldk.models.cpg.base import _NullSafeBase +from cldk.models.cpg.models import ( + Span, Import, Edge, Node, Module, Application, Analyzer, AnalysisPayload, +) + +__all__ = [ + "AnalysisPayload", "Application", "Module", "Node", "Edge", "Span", "Import", "Analyzer", +] diff --git a/cldk/models/cpg/models.py b/cldk/models/cpg/models.py index 50d9ef94..2fdee72f 100644 --- a/cldk/models/cpg/models.py +++ b/cldk/models/cpg/models.py @@ -56,3 +56,37 @@ class Node(_NullSafeBase): of: Optional[str] = None # open vocab tags: Dict[str, str] = {} + + +class Module(_NullSafeBase): + id: str + kind: str = "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: str = "application" + symbol_table: Dict[str, Module] = {} + call_graph: List[Edge] = [] + param_in: List[Edge] = [] + param_out: List[Edge] = [] + + +class Analyzer(_NullSafeBase): + name: str + version: Optional[str] = None + + +class AnalysisPayload(_NullSafeBase): + schema_version: str + language: str + max_level: int + k_limit: Optional[int] = None + analyzer: Optional[Analyzer] = None + application: Application diff --git a/tests/models/cpg/test_containers.py b/tests/models/cpg/test_containers.py new file mode 100644 index 00000000..66fdc29c --- /dev/null +++ b/tests/models/cpg/test_containers.py @@ -0,0 +1,27 @@ +from cldk.models.cpg import AnalysisPayload, Application, Module, Node, Edge, Span, Import, Analyzer + + +def test_envelope_reads_authoritative_level(): + p = AnalysisPayload(**{ + "schema_version": "2.0.0", "language": "python", "max_level": 4, "k_limit": 3, + "analyzer": {"name": "codeanalyzer-python", "version": "0.4.0"}, + "application": {"id": "can://python/app", "kind": "application", "symbol_table": {}}, + }) + assert p.schema_version == "2.0.0" and p.max_level == 4 and p.k_limit == 3 + assert p.analyzer.name == "codeanalyzer-python" + assert p.application.id == "can://python/app" + + +def test_module_holds_source_and_containment(): + m = Module(**{"id": "can://python/app/m.py", "kind": "module", "source": "x = 1\n", + "types": {"C": {"id": "can://python/app/m.py/C", "kind": "class"}}, + "functions": {"f()": {"id": "can://python/app/m.py/f()", "kind": "function"}}}) + assert m.source == "x = 1\n" + assert m.types["C"].kind == "class" and m.functions["f()"].kind == "function" + + +def test_application_edge_lists(): + a = Application(**{"id": "can://python/app", "kind": "application", "symbol_table": {}, + "call_graph": [{"src": "a", "dst": "b", "prov": ["jedi"], "weight": 1}], + "param_in": [{"src": "c@in", "dst": "d@in"}]}) + assert a.call_graph[0].dst == "b" and a.param_in[0].src == "c@in" and a.param_out == [] From 3fe8427c8d550e17119795e612a64305a4a2a2a5 Mon Sep 17 00:00:00 2001 From: Rahul Krishna Date: Tue, 14 Jul 2026 21:41:28 -0400 Subject: [PATCH 06/12] test(cpg): cover Application->Module->Node deep composition (#240) --- tests/models/cpg/test_containers.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/tests/models/cpg/test_containers.py b/tests/models/cpg/test_containers.py index 66fdc29c..a7466364 100644 --- a/tests/models/cpg/test_containers.py +++ b/tests/models/cpg/test_containers.py @@ -25,3 +25,26 @@ def test_application_edge_lists(): "call_graph": [{"src": "a", "dst": "b", "prov": ["jedi"], "weight": 1}], "param_in": [{"src": "c@in", "dst": "d@in"}]}) assert a.call_graph[0].dst == "b" and a.param_in[0].src == "c@in" and a.param_out == [] + + +def test_symbol_table_deep_composition(): + from cldk.models.cpg import Application, Module, Node + a = Application(**{ + "id": "can://python/app", "kind": "application", + "symbol_table": { + "pkg/m.py": { + "id": "can://python/app/pkg/m.py", "kind": "module", "source": "x = 1\n", + "types": {"C": {"id": "can://python/app/pkg/m.py/C", "kind": "class", + "callables": {"C.f()": {"id": "can://python/app/pkg/m.py/C/f()", + "kind": "method", "signature": "f"}}}}, + "functions": {"g()": {"id": "can://python/app/pkg/m.py/g()", "kind": "function"}}, + } + }, + }) + mod = a.symbol_table["pkg/m.py"] + assert isinstance(mod, Module) and mod.source == "x = 1\n" + cls = mod.types["C"] + assert isinstance(cls, Node) and cls.kind == "class" + method = cls.callables["C.f()"] + assert isinstance(method, Node) and method.kind == "method" and method.signature == "f" + assert isinstance(mod.functions["g()"], Node) From 8f03d129eb80dc0d1f4b5bb18ed707c0342453c5 Mon Sep 17 00:00:00 2001 From: Rahul Krishna Date: Tue, 14 Jul 2026 21:46:49 -0400 Subject: [PATCH 07/12] test(cpg): parse real L1/L4 samples from both analyzers + superset gate (#240) --- cldk/models/cpg/models.py | 2 +- tests/models/cpg/test_real_samples.py | 43 +++++++++++++++++++++++++++ tests/resources/cpg/py-a1.json | 1 + tests/resources/cpg/py-a4.json | 1 + tests/resources/cpg/ts-a1.json | 1 + tests/resources/cpg/ts-a4.json | 1 + 6 files changed, 48 insertions(+), 1 deletion(-) create mode 100644 tests/models/cpg/test_real_samples.py create mode 100644 tests/resources/cpg/py-a1.json create mode 100644 tests/resources/cpg/py-a4.json create mode 100644 tests/resources/cpg/ts-a1.json create mode 100644 tests/resources/cpg/ts-a4.json diff --git a/cldk/models/cpg/models.py b/cldk/models/cpg/models.py index 2fdee72f..f829885c 100644 --- a/cldk/models/cpg/models.py +++ b/cldk/models/cpg/models.py @@ -26,7 +26,7 @@ class Import(_NullSafeBase): class Node(_NullSafeBase): - id: str + id: Optional[str] = None # absent on body-node facets (keyed by position/tag instead) kind: str span: Optional[Span] = None parent: Optional[str] = None diff --git a/tests/models/cpg/test_real_samples.py b/tests/models/cpg/test_real_samples.py new file mode 100644 index 00000000..77229190 --- /dev/null +++ b/tests/models/cpg/test_real_samples.py @@ -0,0 +1,43 @@ +"""The models must parse REAL, conformant analysis.json from BOTH analyzers at L1 and L4, and the +L1 tree must be a subset of the L4 tree (additive-levels invariant).""" +import json +from pathlib import Path +import pytest +from cldk.models.cpg import AnalysisPayload + +RES = Path(__file__).parent.parent.parent / "resources" / "cpg" + + +def _load(name): + return AnalysisPayload(**json.loads((RES / name).read_text())) + + +@pytest.mark.parametrize("name,lang,level", [ + ("py-a1.json", "python", 1), ("py-a4.json", "python", 4), + ("ts-a1.json", "typescript", 1), ("ts-a4.json", "typescript", 4), +]) +def test_real_sample_parses(name, lang, level): + p = _load(name) + assert p.schema_version == "2.0.0" and p.language == lang and p.max_level == level + assert p.application.symbol_table # non-empty tree + # every call-graph edge is identity-only src/dst (no dangling shape) + for e in p.application.call_graph: + assert e.src and e.dst + + +def _keys(obj, prefix=""): + out = set() + if isinstance(obj, dict): + for k, v in obj.items(): + out.add(prefix + str(k)); out |= _keys(v, prefix + str(k) + "/") + elif isinstance(obj, list): + for v in obj: + out |= _keys(v, prefix + "[]/") + return out + + +@pytest.mark.parametrize("lo,hi", [("py-a1.json", "py-a4.json"), ("ts-a1.json", "ts-a4.json")]) +def test_l1_subset_of_l4(lo, hi): + lo_t = json.loads((RES / lo).read_text())["application"]["symbol_table"] + hi_t = json.loads((RES / hi).read_text())["application"]["symbol_table"] + assert not (_keys(lo_t) - _keys(hi_t)), "L1 tree keys must be a subset of L4" diff --git a/tests/resources/cpg/py-a1.json b/tests/resources/cpg/py-a1.json new file mode 100644 index 00000000..8890155a --- /dev/null +++ b/tests/resources/cpg/py-a1.json @@ -0,0 +1 @@ +{"schema_version":"2.0.0","language":"python","max_level":1,"analyzer":{"name":"codeanalyzer-python","version":"1.0.0","config":{"analysis_level":1}},"application":{"symbol_table":{"pkg/__init__.py":{"file_path":"/private/tmp/claude-501/-Users-rkrsn-workspace-codellm-devkit-python-sdk/d8e0db27-8f7f-482f-9d10-132b01e7abe7/scratchpad/v2-confirm/pyfix/pkg/__init__.py","module_name":"__init__","id":"can://python/pyfix/pkg/__init__.py","kind":"module","source":"","imports":[],"comments":[],"types":{},"functions":{},"variables":[],"content_hash":"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855","last_modified":1784053428.8736746,"file_size":0},"pkg/mod.py":{"file_path":"/private/tmp/claude-501/-Users-rkrsn-workspace-codellm-devkit-python-sdk/d8e0db27-8f7f-482f-9d10-132b01e7abe7/scratchpad/v2-confirm/pyfix/pkg/mod.py","module_name":"mod","id":"can://python/pyfix/pkg/mod.py","kind":"module","source":"class ResUsers:\n def reset_password(self, login):\n return self._action_reset_password([login])\n def _action_reset_password(self, ids):\n return list(ids)\ndef entry():\n return ResUsers().reset_password(\"x\")\n","imports":[],"comments":[],"types":{"pkg.mod.ResUsers":{"name":"ResUsers","signature":"pkg.mod.ResUsers","id":"can://python/pyfix/pkg/mod.py/ResUsers","kind":"class","span":{"start":[1,0],"end":[5,24],"bytes":[0,172]},"comments":[],"base_classes":[],"callables":{"reset_password":{"name":"reset_password","path":"/private/tmp/claude-501/-Users-rkrsn-workspace-codellm-devkit-python-sdk/d8e0db27-8f7f-482f-9d10-132b01e7abe7/scratchpad/v2-confirm/pyfix/pkg/mod.py","signature":"pkg.mod.ResUsers.reset_password","id":"can://python/pyfix/pkg/mod.py/ResUsers/reset_password(self,login)","kind":"function","span":{"start":[2,4],"end":[3,51],"bytes":[20,104]},"comments":[],"decorators":[],"parameters":[{"name":"self","type":"ResUsers","start_line":2,"end_line":2,"start_column":23,"end_column":27},{"name":"login","type":"str","start_line":2,"end_line":2,"start_column":29,"end_column":34}],"start_line":2,"end_line":3,"code_start_line":3,"accessed_symbols":[{"name":"self","scope":"local","kind":"variable","type":"ResUsers","qualified_name":"pkg.mod.ResUsers","is_builtin":false,"lineno":3,"col_offset":15},{"name":"login","scope":"local","kind":"variable","type":"list","qualified_name":"builtins.list","is_builtin":false,"lineno":3,"col_offset":44}],"call_sites":[{"method_name":"_action_reset_password","receiver_expr":"self","receiver_type":"ResUsers","argument_types":["list"],"arguments":[{"ast_kind":"List","inferred_type":"list"}],"return_type":"list","callee_signature":"pkg.mod.ResUsers._action_reset_password","is_constructor_call":false,"start_line":3,"start_column":15,"end_line":3,"end_column":51}],"callables":{},"types":{},"local_variables":[],"cyclomatic_complexity":2,"body":{"3:15":{"kind":"call","span":{"start":[3,15],"end":[3,51],"bytes":[68,104]}}},"cfg":[],"cdg":[],"ddg":[],"summary":[]},"_action_reset_password":{"name":"_action_reset_password","path":"/private/tmp/claude-501/-Users-rkrsn-workspace-codellm-devkit-python-sdk/d8e0db27-8f7f-482f-9d10-132b01e7abe7/scratchpad/v2-confirm/pyfix/pkg/mod.py","signature":"pkg.mod.ResUsers._action_reset_password","id":"can://python/pyfix/pkg/mod.py/ResUsers/_action_reset_password(self,ids)","kind":"function","span":{"start":[4,4],"end":[5,24],"bytes":[109,172]},"comments":[],"decorators":[],"parameters":[{"name":"self","type":"ResUsers","start_line":4,"end_line":4,"start_column":31,"end_column":35},{"name":"ids","type":"list","start_line":4,"end_line":4,"start_column":37,"end_column":40}],"start_line":4,"end_line":5,"code_start_line":5,"accessed_symbols":[{"name":"list","scope":"local","kind":"class","type":"list","qualified_name":"builtins.list","is_builtin":false,"lineno":5,"col_offset":15},{"name":"ids","scope":"local","kind":"variable","type":"list","qualified_name":"builtins.list","is_builtin":false,"lineno":5,"col_offset":20}],"call_sites":[{"method_name":"list","argument_types":["list"],"arguments":[{"ast_kind":"Name","inferred_type":"list"}],"return_type":"list","callee_signature":"builtins.list.__init__","is_constructor_call":true,"start_line":5,"start_column":15,"end_line":5,"end_column":24}],"callables":{},"types":{},"local_variables":[],"cyclomatic_complexity":2,"body":{"5:15":{"kind":"call","span":{"start":[5,15],"end":[5,24],"bytes":[163,172]}}},"cfg":[],"cdg":[],"ddg":[],"summary":[]}},"attributes":{},"types":{},"start_line":1,"end_line":5}},"functions":{"entry":{"name":"entry","path":"/private/tmp/claude-501/-Users-rkrsn-workspace-codellm-devkit-python-sdk/d8e0db27-8f7f-482f-9d10-132b01e7abe7/scratchpad/v2-confirm/pyfix/pkg/mod.py","signature":"pkg.mod.entry","id":"can://python/pyfix/pkg/mod.py/entry()","kind":"function","span":{"start":[6,0],"end":[7,41],"bytes":[173,227]},"comments":[],"decorators":[],"parameters":[],"start_line":6,"end_line":7,"code_start_line":7,"accessed_symbols":[{"name":"ResUsers","scope":"local","kind":"class","type":"ResUsers","qualified_name":"pkg.mod.ResUsers","is_builtin":false,"lineno":7,"col_offset":11}],"call_sites":[{"method_name":"reset_password","receiver_expr":"ResUsers()","receiver_type":"ResUsers","argument_types":["str"],"arguments":[{"ast_kind":"Constant","inferred_type":"str"}],"return_type":"list","callee_signature":"pkg.mod.ResUsers.reset_password","is_constructor_call":false,"start_line":7,"start_column":11,"end_line":7,"end_column":41},{"method_name":"ResUsers","argument_types":[],"arguments":[],"return_type":"ResUsers","callee_signature":"pkg.mod.ResUsers.__init__","is_constructor_call":true,"start_line":7,"start_column":11,"end_line":7,"end_column":21}],"callables":{},"types":{},"local_variables":[],"cyclomatic_complexity":2,"body":{"7:11":{"kind":"call","span":{"start":[7,11],"end":[7,21],"bytes":[197,207]}}},"cfg":[],"cdg":[],"ddg":[],"summary":[]}},"variables":[],"content_hash":"52fd7728f588c48291e131b8819c7e48fd7956dfd236ca93ccd366a57a82fc29","last_modified":1784053428.887157,"file_size":228}},"id":"can://python/pyfix","kind":"application","call_graph":[{"src":"can://python/pyfix/pkg/mod.py/entry()","dst":"can://python/pyfix/pkg/mod.py/ResUsers/reset_password(self,login)","weight":1,"prov":["jedi"]},{"src":"can://python/pyfix/pkg/mod.py/entry()","dst":"can://python/pyfix/@external/pkg.mod.ResUsers/__init__","weight":1,"prov":["jedi"]},{"src":"can://python/pyfix/pkg/mod.py/ResUsers/reset_password(self,login)","dst":"can://python/pyfix/pkg/mod.py/ResUsers/_action_reset_password(self,ids)","weight":1,"prov":["jedi"]},{"src":"can://python/pyfix/pkg/mod.py/ResUsers/_action_reset_password(self,ids)","dst":"can://python/pyfix/@external/builtins.list/__init__","weight":1,"prov":["jedi"]}],"external_symbols":{"can://python/pyfix/@external/pkg.mod.ResUsers/__init__":{"id":"can://python/pyfix/@external/pkg.mod.ResUsers/__init__","kind":"external","name":"__init__","module":"pkg.mod.ResUsers"},"can://python/pyfix/@external/builtins.list/__init__":{"id":"can://python/pyfix/@external/builtins.list/__init__","kind":"external","name":"__init__","module":"builtins.list"}},"param_in":[],"param_out":[]}} \ No newline at end of file diff --git a/tests/resources/cpg/py-a4.json b/tests/resources/cpg/py-a4.json new file mode 100644 index 00000000..408a0ab1 --- /dev/null +++ b/tests/resources/cpg/py-a4.json @@ -0,0 +1 @@ +{"schema_version":"2.0.0","language":"python","max_level":4,"k_limit":3,"analyzer":{"name":"codeanalyzer-python","version":"1.0.0","config":{"analysis_level":4}},"application":{"symbol_table":{"pkg/__init__.py":{"file_path":"/private/tmp/claude-501/-Users-rkrsn-workspace-codellm-devkit-python-sdk/d8e0db27-8f7f-482f-9d10-132b01e7abe7/scratchpad/v2-confirm/pyfix/pkg/__init__.py","module_name":"__init__","id":"can://python/pyfix/pkg/__init__.py","kind":"module","source":"","imports":[],"comments":[],"types":{},"functions":{},"variables":[],"content_hash":"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855","last_modified":1784053428.8736746,"file_size":0},"pkg/mod.py":{"file_path":"/private/tmp/claude-501/-Users-rkrsn-workspace-codellm-devkit-python-sdk/d8e0db27-8f7f-482f-9d10-132b01e7abe7/scratchpad/v2-confirm/pyfix/pkg/mod.py","module_name":"mod","id":"can://python/pyfix/pkg/mod.py","kind":"module","source":"class ResUsers:\n def reset_password(self, login):\n return self._action_reset_password([login])\n def _action_reset_password(self, ids):\n return list(ids)\ndef entry():\n return ResUsers().reset_password(\"x\")\n","imports":[],"comments":[],"types":{"pkg.mod.ResUsers":{"name":"ResUsers","signature":"pkg.mod.ResUsers","id":"can://python/pyfix/pkg/mod.py/ResUsers","kind":"class","span":{"start":[1,0],"end":[5,24],"bytes":[0,172]},"comments":[],"base_classes":[],"callables":{"reset_password":{"name":"reset_password","path":"/private/tmp/claude-501/-Users-rkrsn-workspace-codellm-devkit-python-sdk/d8e0db27-8f7f-482f-9d10-132b01e7abe7/scratchpad/v2-confirm/pyfix/pkg/mod.py","signature":"pkg.mod.ResUsers.reset_password","id":"can://python/pyfix/pkg/mod.py/ResUsers/reset_password(self,login)","kind":"function","span":{"start":[2,4],"end":[3,51],"bytes":[20,104]},"comments":[],"decorators":[],"parameters":[{"name":"self","type":"ResUsers","start_line":2,"end_line":2,"start_column":23,"end_column":27},{"name":"login","type":"str","start_line":2,"end_line":2,"start_column":29,"end_column":34}],"start_line":2,"end_line":3,"code_start_line":3,"accessed_symbols":[{"name":"self","scope":"local","kind":"variable","type":"ResUsers","qualified_name":"pkg.mod.ResUsers","is_builtin":false,"lineno":3,"col_offset":15},{"name":"login","scope":"local","kind":"variable","type":"list","qualified_name":"builtins.list","is_builtin":false,"lineno":3,"col_offset":44}],"call_sites":[{"method_name":"_action_reset_password","receiver_expr":"self","receiver_type":"ResUsers","argument_types":["list"],"arguments":[{"ast_kind":"List","inferred_type":"list"}],"return_type":"list","callee_signature":"pkg.mod.ResUsers._action_reset_password","is_constructor_call":false,"start_line":3,"start_column":15,"end_line":3,"end_column":51}],"callables":{},"types":{},"local_variables":[],"cyclomatic_complexity":2,"body":{"3:15":{"kind":"call","span":{"start":[3,15],"end":[3,51],"bytes":[68,104]},"callee":"can://python/pyfix/pkg/mod.py/ResUsers/_action_reset_password(self,ids)"},"@entry":{"kind":"entry"},"3:8":{"kind":"return","span":{"start":[3,8],"end":[3,51],"bytes":[61,104]}},"@exit":{"kind":"exit"},"@formal_in:0":{"kind":"formal_in","of":"self"},"@formal_in:1":{"kind":"formal_in","of":"login"},"@formal_out:0":{"kind":"formal_out","of":""},"@formal_out:1":{"kind":"formal_out","of":"self"},"3:8/actual_in:0":{"kind":"actual_in","of":"self","parent":"3:8"},"3:8/actual_in:1":{"kind":"actual_in","of":"ids","parent":"3:8"},"3:8/actual_out":{"kind":"actual_out","of":"","parent":"3:8"}},"cfg":[{"src":"@entry","dst":"3:8","kind":"fallthrough"},{"src":"3:8","dst":"@exit","kind":"exception"},{"src":"3:8","dst":"@exit","kind":"return"}],"cdg":[{"src":"@entry","dst":"3:8"}],"ddg":[{"src":"@entry","dst":"3:8","var":"login","prov":["ssa"]},{"src":"@entry","dst":"3:8","var":"self","prov":["ssa"]},{"src":"@entry","dst":"3:8","var":"self._action_reset_password","prov":["ssa"]}],"summary":[{"src":"3:8/actual_in:1","dst":"3:8/actual_out"}]},"_action_reset_password":{"name":"_action_reset_password","path":"/private/tmp/claude-501/-Users-rkrsn-workspace-codellm-devkit-python-sdk/d8e0db27-8f7f-482f-9d10-132b01e7abe7/scratchpad/v2-confirm/pyfix/pkg/mod.py","signature":"pkg.mod.ResUsers._action_reset_password","id":"can://python/pyfix/pkg/mod.py/ResUsers/_action_reset_password(self,ids)","kind":"function","span":{"start":[4,4],"end":[5,24],"bytes":[109,172]},"comments":[],"decorators":[],"parameters":[{"name":"self","type":"ResUsers","start_line":4,"end_line":4,"start_column":31,"end_column":35},{"name":"ids","type":"list","start_line":4,"end_line":4,"start_column":37,"end_column":40}],"start_line":4,"end_line":5,"code_start_line":5,"accessed_symbols":[{"name":"list","scope":"local","kind":"class","type":"list","qualified_name":"builtins.list","is_builtin":false,"lineno":5,"col_offset":15},{"name":"ids","scope":"local","kind":"variable","type":"list","qualified_name":"builtins.list","is_builtin":false,"lineno":5,"col_offset":20}],"call_sites":[{"method_name":"list","argument_types":["list"],"arguments":[{"ast_kind":"Name","inferred_type":"list"}],"return_type":"list","callee_signature":"builtins.list.__init__","is_constructor_call":true,"start_line":5,"start_column":15,"end_line":5,"end_column":24}],"callables":{},"types":{},"local_variables":[],"cyclomatic_complexity":2,"body":{"5:15":{"kind":"call","span":{"start":[5,15],"end":[5,24],"bytes":[163,172]},"callee":"can://python/pyfix/@external/builtins.list/__init__"},"@entry":{"kind":"entry"},"5:8":{"kind":"return","span":{"start":[5,8],"end":[5,24],"bytes":[156,172]}},"@exit":{"kind":"exit"},"@formal_in:0":{"kind":"formal_in","of":"self"},"@formal_in:1":{"kind":"formal_in","of":"ids"},"@formal_out:0":{"kind":"formal_out","of":""},"@formal_out:1":{"kind":"formal_out","of":"ids"}},"cfg":[{"src":"@entry","dst":"5:8","kind":"fallthrough"},{"src":"5:8","dst":"@exit","kind":"exception"},{"src":"5:8","dst":"@exit","kind":"return"}],"cdg":[{"src":"@entry","dst":"5:8"}],"ddg":[{"src":"@entry","dst":"5:8","var":"ids","prov":["ssa"]},{"src":"@entry","dst":"5:8","var":"list","prov":["ssa"]}],"summary":[]}},"attributes":{},"types":{},"start_line":1,"end_line":5}},"functions":{"entry":{"name":"entry","path":"/private/tmp/claude-501/-Users-rkrsn-workspace-codellm-devkit-python-sdk/d8e0db27-8f7f-482f-9d10-132b01e7abe7/scratchpad/v2-confirm/pyfix/pkg/mod.py","signature":"pkg.mod.entry","id":"can://python/pyfix/pkg/mod.py/entry()","kind":"function","span":{"start":[6,0],"end":[7,41],"bytes":[173,227]},"comments":[],"decorators":[],"parameters":[],"start_line":6,"end_line":7,"code_start_line":7,"accessed_symbols":[{"name":"ResUsers","scope":"local","kind":"class","type":"ResUsers","qualified_name":"pkg.mod.ResUsers","is_builtin":false,"lineno":7,"col_offset":11}],"call_sites":[{"method_name":"reset_password","receiver_expr":"ResUsers()","receiver_type":"ResUsers","argument_types":["str"],"arguments":[{"ast_kind":"Constant","inferred_type":"str"}],"return_type":"list","callee_signature":"pkg.mod.ResUsers.reset_password","is_constructor_call":false,"start_line":7,"start_column":11,"end_line":7,"end_column":41},{"method_name":"ResUsers","argument_types":[],"arguments":[],"return_type":"ResUsers","callee_signature":"pkg.mod.ResUsers.__init__","is_constructor_call":true,"start_line":7,"start_column":11,"end_line":7,"end_column":21}],"callables":{},"types":{},"local_variables":[],"cyclomatic_complexity":2,"body":{"7:11":{"kind":"call","span":{"start":[7,11],"end":[7,21],"bytes":[197,207]},"callee":"can://python/pyfix/@external/pkg.mod.ResUsers/__init__"},"@entry":{"kind":"entry"},"7:4":{"kind":"return","span":{"start":[7,4],"end":[7,41],"bytes":[190,227]}},"@exit":{"kind":"exit"},"@formal_in:0":{"kind":"formal_in","of":":mod::ResUsers"},"@formal_out":{"kind":"formal_out","of":""},"7:4/actual_in:0":{"kind":"actual_in","of":"login","parent":"7:4"},"7:4/actual_out":{"kind":"actual_out","of":"","parent":"7:4"}},"cfg":[{"src":"@entry","dst":"7:4","kind":"fallthrough"},{"src":"7:4","dst":"@exit","kind":"exception"},{"src":"7:4","dst":"@exit","kind":"return"}],"cdg":[{"src":"@entry","dst":"7:4"}],"ddg":[{"src":"@entry","dst":"7:4","var":"mod::ResUsers","prov":["ssa"]}],"summary":[{"src":"7:4/actual_in:0","dst":"7:4/actual_out"}]}},"variables":[],"content_hash":"52fd7728f588c48291e131b8819c7e48fd7956dfd236ca93ccd366a57a82fc29","last_modified":1784053428.887157,"file_size":228}},"id":"can://python/pyfix","kind":"application","call_graph":[{"src":"can://python/pyfix/pkg/mod.py/entry()","dst":"can://python/pyfix/pkg/mod.py/ResUsers/reset_password(self,login)","weight":2,"prov":["jedi","pycg"]},{"src":"can://python/pyfix/pkg/mod.py/entry()","dst":"can://python/pyfix/@external/pkg.mod.ResUsers/__init__","weight":1,"prov":["jedi"]},{"src":"can://python/pyfix/pkg/mod.py/ResUsers/reset_password(self,login)","dst":"can://python/pyfix/pkg/mod.py/ResUsers/_action_reset_password(self,ids)","weight":2,"prov":["jedi","pycg"]},{"src":"can://python/pyfix/pkg/mod.py/ResUsers/_action_reset_password(self,ids)","dst":"can://python/pyfix/@external/builtins.list/__init__","weight":1,"prov":["jedi"]},{"src":"can://python/pyfix/pkg/mod.py/ResUsers/_action_reset_password(self,ids)","dst":"can://python/pyfix/@external//list","weight":1,"prov":["pycg"]}],"external_symbols":{"can://python/pyfix/@external/pkg.mod.ResUsers/__init__":{"id":"can://python/pyfix/@external/pkg.mod.ResUsers/__init__","kind":"external","name":"__init__","module":"pkg.mod.ResUsers"},"can://python/pyfix/@external/builtins.list/__init__":{"id":"can://python/pyfix/@external/builtins.list/__init__","kind":"external","name":"__init__","module":"builtins.list"},"can://python/pyfix/@external//list":{"id":"can://python/pyfix/@external//list","kind":"external","name":"list","module":""}},"param_in":[{"src":"can://python/pyfix/pkg/mod.py/ResUsers/reset_password(self,login)@3:8/actual_in:0","dst":"can://python/pyfix/pkg/mod.py/ResUsers/_action_reset_password(self,ids)@formal_in:0"},{"src":"can://python/pyfix/pkg/mod.py/ResUsers/reset_password(self,login)@3:8/actual_in:1","dst":"can://python/pyfix/pkg/mod.py/ResUsers/_action_reset_password(self,ids)@formal_in:1"},{"src":"can://python/pyfix/pkg/mod.py/entry()@7:4/actual_in:0","dst":"can://python/pyfix/pkg/mod.py/ResUsers/reset_password(self,login)@formal_in:1"}],"param_out":[{"src":"can://python/pyfix/pkg/mod.py/ResUsers/_action_reset_password(self,ids)@formal_out:0","dst":"can://python/pyfix/pkg/mod.py/ResUsers/reset_password(self,login)@3:8/actual_out"},{"src":"can://python/pyfix/pkg/mod.py/ResUsers/reset_password(self,login)@formal_out:0","dst":"can://python/pyfix/pkg/mod.py/entry()@7:4/actual_out"}]}} \ No newline at end of file diff --git a/tests/resources/cpg/ts-a1.json b/tests/resources/cpg/ts-a1.json new file mode 100644 index 00000000..1a432bb3 --- /dev/null +++ b/tests/resources/cpg/ts-a1.json @@ -0,0 +1 @@ +{"schema_version":"2.0.0","language":"typescript","max_level":1,"analyzer":{"name":"codeanalyzer-typescript","version":"0.5.0"},"application":{"id":"can://typescript/tsfix","kind":"application","symbol_table":{"src/index.ts":{"source":"export class Users {\n resetPassword(login: string): string[] { return this.actionReset([login]); }\n private actionReset(ids: string[]): string[] { return ids.map(i => i); }\n}\nexport function entry(): string[] { return new Users().resetPassword(\"x\"); }\n","imports":[],"exports":[],"comments":[],"is_tsx":false,"is_declaration_file":false,"id":"can://typescript/tsfix/src/index.ts","kind":"module","span":{"start":[1,1],"end":[6,1],"bytes":[0,254]},"types":{"Users":{"name":"Users","signature":"src/index.Users","comments":[],"decorators":[],"base_classes":[],"implements_types":[],"type_parameters":[],"entrypoints":[],"is_abstract":false,"is_exported":true,"is_ambient":false,"id":"can://typescript/tsfix/src/index.ts/Users","kind":"class","span":{"start":[1,1],"end":[4,2],"bytes":[0,176]},"callables":{"resetPassword":{"name":"resetPassword","signature":"src/index.Users.resetPassword","comments":[],"decorators":[],"parameters":[{"name":"login","type":"string","is_optional":false,"is_rest":false,"is_readonly":false,"decorators":[],"start_line":2,"end_line":2,"start_column":17,"end_column":30}],"type_parameters":[],"return_type":"string[]","accessed_symbols":[],"cyclomatic_complexity":1,"entrypoints":[],"kind":"method","is_static":false,"is_abstract":false,"is_async":false,"is_generator":false,"is_optional":false,"is_readonly":false,"is_exported":false,"is_ambient":false,"is_implicit":false,"overload_signatures":[],"id":"can://typescript/tsfix/src/index.ts/Users/resetPassword","span":{"start":[2,3],"end":[2,79],"bytes":[23,99]},"body":{"2:51":{"method_name":"actionReset","receiver_expr":"this","receiver_type":"this","argument_types":["string[]"],"type_arguments":[],"return_type":"string[]","is_constructor_call":false,"is_optional_chain":false,"kind":"call","span":{"start":[2,51],"end":[2,76],"bytes":[71,96]},"callee":null}}},"actionReset":{"name":"actionReset","signature":"src/index.Users.actionReset","comments":[],"decorators":[],"parameters":[{"name":"ids","type":"string[]","is_optional":false,"is_rest":false,"is_readonly":false,"decorators":[],"start_line":3,"end_line":3,"start_column":23,"end_column":36}],"type_parameters":[],"return_type":"string[]","accessed_symbols":[],"cyclomatic_complexity":1,"entrypoints":[],"kind":"method","accessibility":"private","is_static":false,"is_abstract":false,"is_async":false,"is_generator":false,"is_optional":false,"is_readonly":false,"is_exported":false,"is_ambient":false,"is_implicit":false,"overload_signatures":[],"id":"can://typescript/tsfix/src/index.ts/Users/actionReset","span":{"start":[3,3],"end":[3,75],"bytes":[102,174]},"body":{"3:57":{"method_name":"map","receiver_expr":"ids","receiver_type":"string[]","argument_types":["(i: string) => string"],"type_arguments":[],"return_type":"string[]","is_constructor_call":false,"is_optional_chain":false,"kind":"call","span":{"start":[3,57],"end":[3,72],"bytes":[156,171]},"callee":null}}},"constructor":{"name":"constructor","signature":"src/index.Users.constructor","comments":[],"decorators":[],"parameters":[],"type_parameters":[],"accessed_symbols":[],"cyclomatic_complexity":0,"entrypoints":[],"kind":"constructor","is_static":false,"is_abstract":false,"is_async":false,"is_generator":false,"is_optional":false,"is_readonly":false,"is_exported":false,"is_ambient":false,"is_implicit":true,"overload_signatures":[],"id":"can://typescript/tsfix/src/index.ts/Users/constructor","span":{"start":[0,0],"end":[0,0],"bytes":[0,0]},"body":{}}},"fields":{}}},"functions":{"entry":{"name":"entry","signature":"src/index.entry","comments":[],"decorators":[],"parameters":[],"type_parameters":[],"return_type":"string[]","accessed_symbols":[],"cyclomatic_complexity":1,"entrypoints":[],"kind":"function","is_static":false,"is_abstract":false,"is_async":false,"is_generator":false,"is_optional":false,"is_readonly":false,"is_exported":true,"is_ambient":false,"is_implicit":false,"overload_signatures":[],"id":"can://typescript/tsfix/src/index.ts/entry","span":{"start":[5,1],"end":[5,77],"bytes":[177,253]},"body":{"5:44":{"method_name":"resetPassword","receiver_expr":"new Users()","receiver_type":"Users","argument_types":["\"x\""],"type_arguments":[],"return_type":"string[]","is_constructor_call":false,"is_optional_chain":false,"kind":"call","span":{"start":[5,44],"end":[5,74],"bytes":[220,250]},"callee":null},"5:44/2":{"method_name":"Users","argument_types":[],"type_arguments":[],"return_type":"Users","is_constructor_call":true,"is_optional_chain":false,"kind":"call","span":{"start":[5,44],"end":[5,55],"bytes":[220,231]},"callee":null}}}},"fields":{}}},"call_graph":[],"param_in":[],"param_out":[]}} \ No newline at end of file diff --git a/tests/resources/cpg/ts-a4.json b/tests/resources/cpg/ts-a4.json new file mode 100644 index 00000000..b9a7f4f3 --- /dev/null +++ b/tests/resources/cpg/ts-a4.json @@ -0,0 +1 @@ +{"schema_version":"2.0.0","language":"typescript","max_level":4,"k_limit":3,"analyzer":{"name":"codeanalyzer-typescript","version":"0.5.0"},"application":{"id":"can://typescript/tsfix","kind":"application","symbol_table":{"src/index.ts":{"source":"export class Users {\n resetPassword(login: string): string[] { return this.actionReset([login]); }\n private actionReset(ids: string[]): string[] { return ids.map(i => i); }\n}\nexport function entry(): string[] { return new Users().resetPassword(\"x\"); }\n","imports":[],"exports":[],"comments":[],"is_tsx":false,"is_declaration_file":false,"id":"can://typescript/tsfix/src/index.ts","kind":"module","span":{"start":[1,1],"end":[6,1],"bytes":[0,254]},"types":{"Users":{"name":"Users","signature":"src/index.Users","comments":[],"decorators":[],"base_classes":[],"implements_types":[],"type_parameters":[],"entrypoints":[],"is_abstract":false,"is_exported":true,"is_ambient":false,"id":"can://typescript/tsfix/src/index.ts/Users","kind":"class","span":{"start":[1,1],"end":[4,2],"bytes":[0,176]},"callables":{"resetPassword":{"name":"resetPassword","signature":"src/index.Users.resetPassword","comments":[],"decorators":[],"parameters":[{"name":"login","type":"string","is_optional":false,"is_rest":false,"is_readonly":false,"decorators":[],"start_line":2,"end_line":2,"start_column":17,"end_column":30}],"type_parameters":[],"return_type":"string[]","accessed_symbols":[],"cyclomatic_complexity":1,"entrypoints":[],"kind":"method","is_static":false,"is_abstract":false,"is_async":false,"is_generator":false,"is_optional":false,"is_readonly":false,"is_exported":false,"is_ambient":false,"is_implicit":false,"overload_signatures":[],"id":"can://typescript/tsfix/src/index.ts/Users/resetPassword","span":{"start":[2,3],"end":[2,79],"bytes":[23,99]},"body":{"2:51":{"method_name":"actionReset","receiver_expr":"this","receiver_type":"this","argument_types":["string[]"],"type_arguments":[],"return_type":"string[]","is_constructor_call":false,"is_optional_chain":false,"kind":"call","span":{"start":[2,51],"end":[2,76],"bytes":[71,96]},"callee":"can://typescript/tsfix/src/index.ts/Users/actionReset"},"@entry":{"kind":"entry","span":{"start":[2,3],"end":[2,79],"bytes":[23,99]}},"2:44":{"kind":"statement","span":{"start":[2,44],"end":[2,77],"bytes":[64,97]}},"@exit":{"kind":"exit","span":{"start":[2,3],"end":[2,79],"bytes":[23,99]}},"@formal_in:0":{"kind":"formal_in","of":"login"},"@formal_out":{"kind":"formal_out","of":"$ret"},"2:44/actual_out":{"kind":"actual_out","of":"$ret","parent":"2:44"},"2:44/actual_in:0":{"kind":"actual_in","of":"arg0","parent":"2:44"}},"cfg":[{"src":"@entry","dst":"2:44","kind":"fallthrough"},{"src":"2:44","dst":"@exit","kind":"exception"},{"src":"2:44","dst":"@exit","kind":"return"}],"cdg":[{"src":"@entry","dst":"2:44"}],"ddg":[{"src":"@entry","dst":"2:44","var":"login","prov":["reaching-defs"]},{"src":"@entry","dst":"2:44","var":"this.actionReset","prov":["reaching-defs"]},{"src":"2:44","dst":"@formal_out","var":"return","prov":["reaching-defs"]}],"summary":[{"src":"2:44/actual_in:0","dst":"2:44/actual_out"}]},"actionReset":{"name":"actionReset","signature":"src/index.Users.actionReset","comments":[],"decorators":[],"parameters":[{"name":"ids","type":"string[]","is_optional":false,"is_rest":false,"is_readonly":false,"decorators":[],"start_line":3,"end_line":3,"start_column":23,"end_column":36}],"type_parameters":[],"return_type":"string[]","accessed_symbols":[],"cyclomatic_complexity":1,"entrypoints":[],"kind":"method","accessibility":"private","is_static":false,"is_abstract":false,"is_async":false,"is_generator":false,"is_optional":false,"is_readonly":false,"is_exported":false,"is_ambient":false,"is_implicit":false,"overload_signatures":[],"id":"can://typescript/tsfix/src/index.ts/Users/actionReset","span":{"start":[3,3],"end":[3,75],"bytes":[102,174]},"body":{"3:57":{"method_name":"map","receiver_expr":"ids","receiver_type":"string[]","argument_types":["(i: string) => string"],"type_arguments":[],"return_type":"string[]","is_constructor_call":false,"is_optional_chain":false,"kind":"call","span":{"start":[3,57],"end":[3,72],"bytes":[156,171]},"callee":null},"@entry":{"kind":"entry","span":{"start":[3,3],"end":[3,75],"bytes":[102,174]}},"3:50":{"kind":"statement","span":{"start":[3,50],"end":[3,73],"bytes":[149,172]}},"@exit":{"kind":"exit","span":{"start":[3,3],"end":[3,75],"bytes":[102,174]}},"@formal_in:0":{"kind":"formal_in","of":"ids"},"@formal_out":{"kind":"formal_out","of":"$ret"},"3:50/actual_in:0":{"kind":"actual_in","of":"arg0","parent":"3:50"},"3:50/actual_out":{"kind":"actual_out","of":"$ret","parent":"3:50"}},"cfg":[{"src":"@entry","dst":"3:50","kind":"fallthrough"},{"src":"3:50","dst":"@exit","kind":"exception"},{"src":"3:50","dst":"@exit","kind":"return"}],"cdg":[{"src":"@entry","dst":"3:50"}],"ddg":[{"src":"@entry","dst":"3:50","var":"ids.map","prov":["reaching-defs"]},{"src":"3:50","dst":"@formal_out","var":"return","prov":["reaching-defs"]}],"summary":[{"src":"3:50/actual_in:0","dst":"3:50/actual_out"}]},"constructor":{"name":"constructor","signature":"src/index.Users.constructor","comments":[],"decorators":[],"parameters":[],"type_parameters":[],"accessed_symbols":[],"cyclomatic_complexity":0,"entrypoints":[],"kind":"constructor","is_static":false,"is_abstract":false,"is_async":false,"is_generator":false,"is_optional":false,"is_readonly":false,"is_exported":false,"is_ambient":false,"is_implicit":true,"overload_signatures":[],"id":"can://typescript/tsfix/src/index.ts/Users/constructor","span":{"start":[0,0],"end":[0,0],"bytes":[0,0]},"body":{}}},"fields":{}}},"functions":{"entry":{"name":"entry","signature":"src/index.entry","comments":[],"decorators":[],"parameters":[],"type_parameters":[],"return_type":"string[]","accessed_symbols":[],"cyclomatic_complexity":1,"entrypoints":[],"kind":"function","is_static":false,"is_abstract":false,"is_async":false,"is_generator":false,"is_optional":false,"is_readonly":false,"is_exported":true,"is_ambient":false,"is_implicit":false,"overload_signatures":[],"id":"can://typescript/tsfix/src/index.ts/entry","span":{"start":[5,1],"end":[5,77],"bytes":[177,253]},"body":{"5:44":{"method_name":"resetPassword","receiver_expr":"new Users()","receiver_type":"Users","argument_types":["\"x\""],"type_arguments":[],"return_type":"string[]","is_constructor_call":false,"is_optional_chain":false,"kind":"call","span":{"start":[5,44],"end":[5,74],"bytes":[220,250]},"callee":"can://typescript/tsfix/src/index.ts/Users/resetPassword"},"5:44/2":{"method_name":"Users","argument_types":[],"type_arguments":[],"return_type":"Users","is_constructor_call":true,"is_optional_chain":false,"kind":"call","span":{"start":[5,44],"end":[5,55],"bytes":[220,231]},"callee":"can://typescript/tsfix/src/index.ts/Users/constructor"},"@entry":{"kind":"entry","span":{"start":[5,1],"end":[5,77],"bytes":[177,253]}},"5:37":{"kind":"statement","span":{"start":[5,37],"end":[5,75],"bytes":[213,251]}},"@exit":{"kind":"exit","span":{"start":[5,1],"end":[5,77],"bytes":[177,253]}},"@formal_out":{"kind":"formal_out","of":"$ret"},"5:37/actual_in:0":{"kind":"actual_in","of":"arg0","parent":"5:37"},"5:37/actual_out":{"kind":"actual_out","of":"$ret","parent":"5:37"}},"cfg":[{"src":"@entry","dst":"5:37","kind":"fallthrough"},{"src":"5:37","dst":"@exit","kind":"exception"},{"src":"5:37","dst":"@exit","kind":"return"}],"cdg":[{"src":"@entry","dst":"5:37"}],"ddg":[{"src":"5:37","dst":"@formal_out","var":"return","prov":["reaching-defs"]}],"summary":[{"src":"5:37/actual_in:0","dst":"5:37/actual_out"}]}},"fields":{}}},"call_graph":[{"src":"can://typescript/tsfix/src/index.ts/entry","dst":"can://typescript/tsfix/src/index.ts/Users/resetPassword","prov":["tsc","jelly"],"weight":2},{"src":"can://typescript/tsfix/src/index.ts/entry","dst":"can://typescript/tsfix/src/index.ts/Users/constructor","prov":["tsc"],"weight":1},{"src":"can://typescript/tsfix/src/index.ts/Users/resetPassword","dst":"can://typescript/tsfix/src/index.ts/Users/actionReset","prov":["tsc","jelly"],"weight":2},{"src":"can://typescript/tsfix/src/index.ts/Users/actionReset","dst":"can://typescript/tsfix/src/index.ts/Users/actionReset@3:65","prov":["jelly"],"weight":1}],"param_in":[{"src":"can://typescript/tsfix/src/index.ts/entry@5:37/actual_in:0","dst":"can://typescript/tsfix/src/index.ts/Users/resetPassword@formal_in:0"},{"src":"can://typescript/tsfix/src/index.ts/Users/resetPassword@2:44/actual_in:0","dst":"can://typescript/tsfix/src/index.ts/Users/actionReset@formal_in:0"}],"param_out":[{"src":"can://typescript/tsfix/src/index.ts/Users/actionReset@formal_out","dst":"can://typescript/tsfix/src/index.ts/Users/resetPassword@2:44/actual_out"},{"src":"can://typescript/tsfix/src/index.ts/Users/resetPassword@formal_out","dst":"can://typescript/tsfix/src/index.ts/entry@5:37/actual_out"}],"external_symbols":{},"synthesized_callables":{"can://typescript/tsfix/src/index.ts/Users/actionReset@3:65":{"id":"can://typescript/tsfix/src/index.ts/Users/actionReset@3:65","kind":"callable","name":"","path":"src/index.ts","span":{"start":[3,65],"end":[3,65],"bytes":[0,0]}}}}} \ No newline at end of file From 95b2c9ce9947859c2867eafe97a50da6d568f4a3 Mon Sep 17 00:00:00 2001 From: Rahul Krishna Date: Tue, 14 Jul 2026 22:10:38 -0400 Subject: [PATCH 08/12] fix(cpg): enforce id on durable nodes positionally; body nodes exempt (#240) --- cldk/models/cpg/models.py | 22 +++++++++++++++++++++- tests/models/cpg/test_containers.py | 9 +++++++++ tests/models/cpg/test_node.py | 17 +++++++++++++++++ 3 files changed, 47 insertions(+), 1 deletion(-) diff --git a/cldk/models/cpg/models.py b/cldk/models/cpg/models.py index f829885c..45585456 100644 --- a/cldk/models/cpg/models.py +++ b/cldk/models/cpg/models.py @@ -1,5 +1,6 @@ from __future__ import annotations from typing import Any, Dict, List, Optional, Tuple +from pydantic import model_validator from cldk.models.cpg.base import _NullSafeBase @@ -26,7 +27,10 @@ class Import(_NullSafeBase): class Node(_NullSafeBase): - id: Optional[str] = None # absent on body-node facets (keyed by position/tag instead) + # Optional because sub-callable body nodes are keyed by local position and omit id; durable + # nodes (types/callables/functions/fields) are required to carry it — enforced positionally by + # the container validators. + id: Optional[str] = None kind: str span: Optional[Span] = None parent: Optional[str] = None @@ -57,6 +61,14 @@ class Node(_NullSafeBase): # open vocab tags: Dict[str, str] = {} + @model_validator(mode="after") + def _durable_children_require_id(self): + for container in (self.callables, self.fields): + for key, node in container.items(): + if node.id is None: + raise ValueError(f"durable node {key!r} under {self.id or ''!r} is missing required id") + return self + class Module(_NullSafeBase): id: str @@ -68,6 +80,14 @@ class Module(_NullSafeBase): functions: Dict[str, Node] = {} content_hash: Optional[str] = None + @model_validator(mode="after") + def _durable_children_require_id(self): + for container in (self.types, self.functions): + for key, node in container.items(): + if node.id is None: + raise ValueError(f"durable node {key!r} in module {self.id!r} is missing required id") + return self + class Application(_NullSafeBase): id: str diff --git a/tests/models/cpg/test_containers.py b/tests/models/cpg/test_containers.py index a7466364..f61d10a5 100644 --- a/tests/models/cpg/test_containers.py +++ b/tests/models/cpg/test_containers.py @@ -1,3 +1,6 @@ +import pytest +from pydantic import ValidationError + from cldk.models.cpg import AnalysisPayload, Application, Module, Node, Edge, Span, Import, Analyzer @@ -20,6 +23,12 @@ def test_module_holds_source_and_containment(): assert m.types["C"].kind == "class" and m.functions["f()"].kind == "function" +def test_module_durable_node_missing_id_raises(): + # a type reached through the durable-containment dict MUST carry the join key id + with pytest.raises(ValidationError): + Module(**{"id": "m", "types": {"C": {"kind": "class"}}}) # no id on the type + + def test_application_edge_lists(): a = Application(**{"id": "can://python/app", "kind": "application", "symbol_table": {}, "call_graph": [{"src": "a", "dst": "b", "prov": ["jedi"], "weight": 1}], diff --git a/tests/models/cpg/test_node.py b/tests/models/cpg/test_node.py index cc2f749c..bb3f22e7 100644 --- a/tests/models/cpg/test_node.py +++ b/tests/models/cpg/test_node.py @@ -1,3 +1,6 @@ +import pytest +from pydantic import ValidationError + from cldk.models.cpg.models import Node @@ -25,3 +28,17 @@ def test_call_body_node_callee_refines_from_null(): def test_language_extra_field_preserved(): n = Node(**{"id": "x", "kind": "class", "is_abstract": True}) # a language-specific flag assert n.model_extra.get("is_abstract") is True + + +def test_durable_callable_missing_id_raises(): + # a callable reached through the durable-containment dict MUST carry the join key id + with pytest.raises(ValidationError): + Node(**{"id": "can://p/m.py/C", "kind": "class", + "callables": {"C.f()": {"kind": "method"}}}) # no id on the callable + + +def test_body_node_missing_id_parses(): + # body nodes are keyed by local position and legitimately omit id — must NOT raise + n = Node(**{"id": "can://p/m.py/f()", "kind": "function", + "body": {"1:0": {"kind": "statement"}}}) + assert n.body["1:0"].id is None and n.body["1:0"].kind == "statement" From 8b5222a450a18c2e34f85417c8672da211f7cc36 Mon Sep 17 00:00:00 2001 From: Rahul Krishna Date: Tue, 14 Jul 2026 22:18:03 -0400 Subject: [PATCH 09/12] test(cpg): pin the F7/F3 accessor contract against real L4 samples (#240) --- tests/models/cpg/test_accessor_contract.py | 47 ++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 tests/models/cpg/test_accessor_contract.py diff --git a/tests/models/cpg/test_accessor_contract.py b/tests/models/cpg/test_accessor_contract.py new file mode 100644 index 00000000..90fb8518 --- /dev/null +++ b/tests/models/cpg/test_accessor_contract.py @@ -0,0 +1,47 @@ +"""Pin the accessors F7 (Task-7 provider) and F3 (views) depend on, against a real L4 sample.""" +import json +from pathlib import Path +from cldk.models.cpg import AnalysisPayload + +RES = Path(__file__).parent.parent.parent / "resources" / "cpg" + + +def _app(name): + return AnalysisPayload(**json.loads((RES / name).read_text())).application + + +def test_symbol_table_module_source_and_containment(): + app = _app("py-a4.json") + mod = app.symbol_table["pkg/mod.py"] + assert isinstance(mod.source, str) and mod.source # module.source (byte-slice base) + assert mod.types or mod.functions # types{} / functions{} + + +def test_callable_body_and_dataflow_edges_present_at_l4(): + app = _app("py-a4.json") + mod = app.symbol_table["pkg/mod.py"] + cls = next(iter(mod.types.values())) + call = next(iter(cls.callables.values())) + assert call.signature # callable.signature + assert call.body # body{} populated at L4 + assert call.cfg and call.ddg # cfg/ddg edge lists + # span.bytes present for slicing a body node + some = next(iter(call.body.values())) + assert some.span is None or (some.span.bytes and len(some.span.bytes) == 2) + + +def test_application_interprocedural_edges_at_l4(): + app = _app("py-a4.json") + assert app.call_graph # L2 call graph + assert app.param_in and app.param_out # L4 SDG param edges + for e in app.call_graph: + assert e.src.startswith("can://") and e.dst.startswith("can://") # can:// identity + + +def test_typescript_sample_same_accessors(): + app = _app("ts-a4.json") + mod = next(iter(app.symbol_table.values())) + assert isinstance(mod.source, str) + # a TS type node with callables + typ = next((t for t in mod.types.values() if t.callables), None) + assert typ is not None and next(iter(typ.callables.values())).signature is not None From a01245716d009cd874eb2e92ac354276e97c4679 Mon Sep 17 00:00:00 2001 From: Rahul Krishna Date: Tue, 14 Jul 2026 22:26:52 -0400 Subject: [PATCH 10/12] test(cpg): genuinely pin module.functions accessor; strengthen TS source check (#240) --- tests/models/cpg/test_accessor_contract.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/models/cpg/test_accessor_contract.py b/tests/models/cpg/test_accessor_contract.py index 90fb8518..f2ea3f83 100644 --- a/tests/models/cpg/test_accessor_contract.py +++ b/tests/models/cpg/test_accessor_contract.py @@ -14,7 +14,8 @@ def test_symbol_table_module_source_and_containment(): app = _app("py-a4.json") mod = app.symbol_table["pkg/mod.py"] assert isinstance(mod.source, str) and mod.source # module.source (byte-slice base) - assert mod.types or mod.functions # types{} / functions{} + assert isinstance(mod.types, dict) and isinstance(mod.functions, dict) # both accessors pinned + assert mod.types or mod.functions # at least one populated def test_callable_body_and_dataflow_edges_present_at_l4(): @@ -41,7 +42,7 @@ def test_application_interprocedural_edges_at_l4(): def test_typescript_sample_same_accessors(): app = _app("ts-a4.json") mod = next(iter(app.symbol_table.values())) - assert isinstance(mod.source, str) + assert isinstance(mod.source, str) and mod.source # a TS type node with callables typ = next((t for t in mod.types.values() if t.callables), None) assert typ is not None and next(iter(typ.callables.values())).signature is not None From b52d0050df297707d5da6421b81bfb323c232102 Mon Sep 17 00:00:00 2001 From: Rahul Krishna Date: Tue, 14 Jul 2026 22:40:03 -0400 Subject: [PATCH 11/12] 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. --- cldk/models/cpg/models.py | 1 + tests/models/cpg/test_real_samples.py | 12 +++++++++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/cldk/models/cpg/models.py b/cldk/models/cpg/models.py index 45585456..1bbae9f1 100644 --- a/cldk/models/cpg/models.py +++ b/cldk/models/cpg/models.py @@ -73,6 +73,7 @@ def _durable_children_require_id(self): class Module(_NullSafeBase): id: str kind: str = "module" + span: Optional[Span] = None package: Optional[str] = None source: str = "" imports: List[Import] = [] diff --git a/tests/models/cpg/test_real_samples.py b/tests/models/cpg/test_real_samples.py index 77229190..2eaeeda8 100644 --- a/tests/models/cpg/test_real_samples.py +++ b/tests/models/cpg/test_real_samples.py @@ -3,7 +3,7 @@ import json from pathlib import Path import pytest -from cldk.models.cpg import AnalysisPayload +from cldk.models.cpg import AnalysisPayload, Span RES = Path(__file__).parent.parent.parent / "resources" / "cpg" @@ -41,3 +41,13 @@ def test_l1_subset_of_l4(lo, hi): lo_t = json.loads((RES / lo).read_text())["application"]["symbol_table"] hi_t = json.loads((RES / hi).read_text())["application"]["symbol_table"] assert not (_keys(lo_t) - _keys(hi_t)), "L1 tree keys must be a subset of L4" + + +def test_module_span_parses_on_typescript_sample(): + # span is a common field per the keystone (Part II), module included; ts-a4 emits it on the + # module node — it must parse into Span, not fall through to model_extra as a raw dict. + p = _load("ts-a4.json") + mod = next(iter(p.application.symbol_table.values())) + assert isinstance(mod.span, Span) + assert mod.span.bytes == (0, 254) + assert len(mod.span.bytes) == 2 and all(isinstance(b, int) for b in mod.span.bytes) From 6bbce6d70e3d2906bfb78681bc485d09ce6b172d Mon Sep 17 00:00:00 2001 From: Rahul Krishna Date: Tue, 14 Jul 2026 22:46:15 -0400 Subject: [PATCH 12/12] test(cpg): pin cdg/summary/k_limit/TS body+cfg in the accessor contract (#240) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- tests/models/cpg/test_accessor_contract.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/models/cpg/test_accessor_contract.py b/tests/models/cpg/test_accessor_contract.py index f2ea3f83..269ebb23 100644 --- a/tests/models/cpg/test_accessor_contract.py +++ b/tests/models/cpg/test_accessor_contract.py @@ -26,11 +26,24 @@ def test_callable_body_and_dataflow_edges_present_at_l4(): assert call.signature # callable.signature assert call.body # body{} populated at L4 assert call.cfg and call.ddg # cfg/ddg edge lists + # cdg/summary are unpinned elsewhere and are the sole extra="allow" guard for these two + # fields (both in F7's cfg/cdg/ddg/summary read set) — dereference an element attribute so a + # deleted field (which would fall back to a raw dict under extra="allow") fails loudly. + assert isinstance(call.cdg, list) and isinstance(call.summary, list) + assert call.cdg[0].src and call.summary[0].src # span.bytes present for slicing a body node some = next(iter(call.body.values())) assert some.span is None or (some.span.bytes and len(some.span.bytes) == 2) +def test_envelope_k_limit_at_l4(): + payload = AnalysisPayload(**json.loads((RES / "py-a4.json").read_text())) + assert payload.k_limit == 3 + # a plain value check alone would still pass via the extra="allow" passthrough even if + # k_limit were deleted from the model — assert it's a declared field, not an extras leak. + assert "k_limit" not in (payload.model_extra or {}) + + def test_application_interprocedural_edges_at_l4(): app = _app("py-a4.json") assert app.call_graph # L2 call graph @@ -46,3 +59,8 @@ def test_typescript_sample_same_accessors(): # a TS type node with callables typ = next((t for t in mod.types.values() if t.callables), None) assert typ is not None and next(iter(typ.callables.values())).signature is not None + # resetPassword under type Users: body/cfg must resolve to parsed Node/Edge, not raw dicts, + # so the TS analyzer path isn't pinned on source/signature alone. + call = typ.callables["resetPassword"] + assert isinstance(call.body, dict) and next(iter(call.body.values())).kind + assert isinstance(call.cfg, list) and call.cfg[0].kind