codeanalyzer-python 0.3.0__py3-none-any.whl → 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. codeanalyzer/__main__.py +77 -4
  2. codeanalyzer/core.py +174 -72
  3. codeanalyzer/dataflow/__init__.py +35 -0
  4. codeanalyzer/dataflow/access_paths.py +563 -0
  5. codeanalyzer/dataflow/alias.py +93 -0
  6. codeanalyzer/dataflow/builder.py +688 -0
  7. codeanalyzer/dataflow/cfg.py +605 -0
  8. codeanalyzer/dataflow/defuse.py +113 -0
  9. codeanalyzer/dataflow/dominance.py +140 -0
  10. codeanalyzer/dataflow/identity.py +91 -0
  11. codeanalyzer/dataflow/pdg.py +100 -0
  12. codeanalyzer/dataflow/scalpel_oracle.py +269 -0
  13. codeanalyzer/dataflow/scc.py +91 -0
  14. codeanalyzer/dataflow/sdg.py +424 -0
  15. codeanalyzer/dataflow/slicing.py +93 -0
  16. codeanalyzer/dataflow/summaries.py +217 -0
  17. codeanalyzer/dataflow/syntactic.py +26 -0
  18. codeanalyzer/neo4j/__init__.py +1 -1
  19. codeanalyzer/neo4j/bolt.py +19 -4
  20. codeanalyzer/neo4j/cypher.py +9 -3
  21. codeanalyzer/neo4j/emit.py +10 -5
  22. codeanalyzer/neo4j/project.py +307 -60
  23. codeanalyzer/neo4j/rows.py +18 -15
  24. codeanalyzer/neo4j/schema.py +297 -15
  25. codeanalyzer/options/options.py +4 -0
  26. codeanalyzer/provenance.py +61 -0
  27. codeanalyzer/schema/__init__.py +19 -0
  28. codeanalyzer/schema/assign_ids.py +37 -0
  29. codeanalyzer/schema/call_graph_ids.py +12 -0
  30. codeanalyzer/schema/ids.py +23 -0
  31. codeanalyzer/schema/l1_body.py +29 -0
  32. codeanalyzer/schema/l2_callees.py +36 -0
  33. codeanalyzer/schema/py_schema.py +175 -26
  34. codeanalyzer/semantic_analysis/call_graph.py +24 -27
  35. codeanalyzer/semantic_analysis/pycg/pycg_analysis.py +10 -10
  36. codeanalyzer/semantic_analysis/pycg/shard_planner.py +7 -7
  37. codeanalyzer/syntactic_analysis/import_resolver.py +67 -0
  38. codeanalyzer/syntactic_analysis/symbol_table_builder.py +103 -20
  39. {codeanalyzer_python-0.3.0.dist-info → codeanalyzer_python-1.0.0.dist-info}/METADATA +233 -43
  40. codeanalyzer_python-1.0.0.dist-info/RECORD +59 -0
  41. {codeanalyzer_python-0.3.0.dist-info → codeanalyzer_python-1.0.0.dist-info}/WHEEL +1 -1
  42. codeanalyzer/neo4j/catalog.py +0 -245
  43. codeanalyzer_python-0.3.0.dist-info/RECORD +0 -38
  44. {codeanalyzer_python-0.3.0.dist-info → codeanalyzer_python-1.0.0.dist-info}/entry_points.txt +0 -0
  45. {codeanalyzer_python-0.3.0.dist-info → codeanalyzer_python-1.0.0.dist-info}/licenses/LICENSE +0 -0
  46. {codeanalyzer_python-0.3.0.dist-info → codeanalyzer_python-1.0.0.dist-info}/licenses/NOTICE +0 -0
@@ -14,26 +14,308 @@
14
14
  # limitations under the License.
15
15
  ################################################################################
16
16
 
17
- """The Cypher DDL — uniqueness constraints and indexes — shared by both writers.
18
- Run BEFORE any load so MERGE uses an index seek (not a label scan) and the
19
- identity invariant is enforced by the database. Every statement is idempotent
20
- (``IF NOT EXISTS``).
21
17
  """
22
- from typing import List
23
-
24
- CONSTRAINTS: List[str] = [
25
- "CREATE CONSTRAINT py_symbol_sig IF NOT EXISTS FOR (s:PySymbol) REQUIRE s.signature IS UNIQUE",
26
- "CREATE CONSTRAINT py_app_name IF NOT EXISTS FOR (a:PyApplication) REQUIRE a.name IS UNIQUE",
27
- "CREATE CONSTRAINT py_module_key IF NOT EXISTS FOR (m:PyModule) REQUIRE m.file_key IS UNIQUE",
28
- "CREATE CONSTRAINT py_package_name IF NOT EXISTS FOR (p:PyPackage) REQUIRE p.name IS UNIQUE",
29
- "CREATE CONSTRAINT py_decorator_name IF NOT EXISTS FOR (d:PyDecorator) REQUIRE d.name IS UNIQUE",
30
- "CREATE CONSTRAINT py_callsite_id IF NOT EXISTS FOR (c:PyCallSite) REQUIRE c.id IS UNIQUE",
31
- "CREATE CONSTRAINT py_attribute_id IF NOT EXISTS FOR (a:PyAttribute) REQUIRE a.id IS UNIQUE",
32
- "CREATE CONSTRAINT py_variable_id IF NOT EXISTS FOR (v:PyVariable) REQUIRE v.id IS UNIQUE",
18
+ The declarative Neo4j schema — the single in-repo source of truth for the graph contract: node
19
+ labels with their keys and typed properties, relationship types and their endpoints, and the
20
+ Cypher DDL (uniqueness constraints + indexes). The constraints are DERIVED from the node labels
21
+ (one per distinct mergeLabel/key) so a new label brings its own constraint — there is no second
22
+ list to keep in sync. `--emit schema` serializes all of this to a machine-readable schema.json,
23
+ and the conformance test (``test/test_neo4j_schema.py``) asserts the real emitter never produces a
24
+ label / relationship / property that isn't declared here so this file cannot silently drift
25
+ from :mod:`codeanalyzer.neo4j.project`.
26
+
27
+ SCHEMA_VERSION is the contract version: bump MAJOR on a breaking change (renamed/removed label,
28
+ relationship or key), MINOR on an additive change (new label/rel/property). It is stamped onto
29
+ the :PyApplication node of every emitted graph so any consumer can detect a producer/consumer
30
+ mismatch at runtime.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ from dataclasses import dataclass, field
36
+ from typing import Dict, List
37
+
38
+ SCHEMA_VERSION = "2.0.0"
39
+
40
+ # PropType ∈ {"string", "integer", "float", "boolean", "string[]", "integer[]"}.
41
+
42
+
43
+ @dataclass
44
+ class NodeLabel:
45
+ label: str # the specific label (also the catalog key)
46
+ merge_label: str # the label the uniqueness constraint / MERGE is on
47
+ key: str
48
+ properties: Dict[str, str]
49
+
50
+
51
+ @dataclass
52
+ class RelType:
53
+ type: str
54
+ from_labels: List[str]
55
+ to_labels: List[str]
56
+ properties: Dict[str, str] = field(default_factory=dict)
57
+
58
+
59
+ # Labels layered onto a node in addition to its primary/specific label.
60
+ MARKER_LABELS: List[str] = []
61
+
62
+ _SPAN = {"start_line": "integer", "end_line": "integer"}
63
+
64
+
65
+ NODE_LABELS: List[NodeLabel] = [
66
+ NodeLabel(
67
+ "PyApplication",
68
+ "PyApplication",
69
+ "name",
70
+ {
71
+ "name": "string",
72
+ "schema_version": "string",
73
+ "analyzer_name": "string",
74
+ "analyzer_version": "string",
75
+ "repo_uri": "string",
76
+ "source_revision": "string",
77
+ "repo_dirty": "boolean",
78
+ },
79
+ ),
80
+ NodeLabel(
81
+ "PyModule",
82
+ "PyModule",
83
+ "id",
84
+ {
85
+ "id": "string",
86
+ "file_key": "string",
87
+ "module_name": "string",
88
+ "content_hash": "string",
89
+ "last_modified": "float",
90
+ "file_size": "integer",
91
+ "_module": "string",
92
+ },
93
+ ),
94
+ NodeLabel(
95
+ "PyClass",
96
+ "PySymbol",
97
+ "id",
98
+ {
99
+ "id": "string",
100
+ "signature": "string",
101
+ "name": "string",
102
+ "code": "string",
103
+ "base_classes": "string[]",
104
+ "docstring": "string",
105
+ **_SPAN,
106
+ "_module": "string",
107
+ },
108
+ ),
109
+ NodeLabel(
110
+ "PyCallable",
111
+ "PySymbol",
112
+ "id",
113
+ {
114
+ "id": "string",
115
+ "signature": "string",
116
+ "name": "string",
117
+ "path": "string",
118
+ "return_type": "string",
119
+ "cyclomatic_complexity": "integer",
120
+ "code": "string",
121
+ "code_start_line": "integer",
122
+ **_SPAN,
123
+ "docstring": "string",
124
+ "decorators": "string[]",
125
+ "parameters_json": "string",
126
+ "accessed_symbols_json": "string",
127
+ "_module": "string",
128
+ },
129
+ ),
130
+ NodeLabel(
131
+ "PyExternal",
132
+ "PySymbol",
133
+ "id",
134
+ {"id": "string", "name": "string", "module": "string"},
135
+ ),
136
+ NodeLabel("PyPackage", "PyPackage", "name", {"name": "string"}),
137
+ NodeLabel(
138
+ "PyDecorator",
139
+ "PyDecorator",
140
+ "name",
141
+ {"name": "string"},
142
+ ),
143
+ NodeLabel(
144
+ "PyCallSite",
145
+ "PyCallSite",
146
+ "id",
147
+ {
148
+ "id": "string",
149
+ "method_name": "string",
150
+ "receiver_expr": "string",
151
+ "receiver_type": "string",
152
+ "argument_types": "string[]",
153
+ "arguments_json": "string",
154
+ "return_type": "string",
155
+ "callee_signature": "string",
156
+ "is_constructor_call": "boolean",
157
+ "start_line": "integer",
158
+ "start_column": "integer",
159
+ "end_line": "integer",
160
+ "end_column": "integer",
161
+ "_module": "string",
162
+ },
163
+ ),
164
+ NodeLabel(
165
+ "PyAttribute",
166
+ "PyAttribute",
167
+ "id",
168
+ {
169
+ "id": "string",
170
+ "name": "string",
171
+ "type": "string",
172
+ "initializer": "string",
173
+ "docstring": "string",
174
+ **_SPAN,
175
+ "_module": "string",
176
+ },
177
+ ),
178
+ NodeLabel(
179
+ "PyVariable",
180
+ "PyVariable",
181
+ "id",
182
+ {
183
+ "id": "string",
184
+ "name": "string",
185
+ "type": "string",
186
+ "initializer": "string",
187
+ "scope": "string",
188
+ **_SPAN,
189
+ "_module": "string",
190
+ },
191
+ ),
192
+ # Level-3 CPG overlay (present only at -a 3). The dataflow vocabulary is
193
+ # shared cross-language in *shape* (same suffixes, props, semantics) but
194
+ # namespaced per language like every other row family — a multi-language
195
+ # Neo4j database must never mingle one analyzer's dependence edges with
196
+ # another's. `id` = "<signature>#<node_id>"; parameter-passing nodes
197
+ # (formal/actual in/out) ride the same label with `var`/`call_node`.
198
+ NodeLabel(
199
+ "PyCFGNode",
200
+ "PyCFGNode",
201
+ "id",
202
+ {
203
+ "id": "string",
204
+ "kind": "string",
205
+ "var": "string",
206
+ "call_node": "string",
207
+ **_SPAN,
208
+ "_module": "string",
209
+ },
210
+ ),
33
211
  ]
34
212
 
213
+ _DECL_TARGETS = ["PyClass", "PyCallable"]
214
+
215
+
216
+ REL_TYPES: List[RelType] = [
217
+ RelType("PY_HAS_MODULE", ["PyApplication"], ["PyModule"]),
218
+ RelType("PY_DECLARES", ["PyModule", "PyClass", "PyCallable"], _DECL_TARGETS),
219
+ RelType("PY_HAS_METHOD", ["PyClass"], ["PyCallable"]),
220
+ RelType("PY_HAS_ATTRIBUTE", ["PyClass"], ["PyAttribute"]),
221
+ RelType("PY_DECLARES_VAR", ["PyModule", "PyCallable"], ["PyVariable"]),
222
+ RelType("PY_HAS_CALLSITE", ["PyCallable"], ["PyCallSite"]),
223
+ RelType("PY_RESOLVES_TO", ["PyCallSite"], ["PyCallable", "PyExternal"]),
224
+ RelType(
225
+ "PY_CALLS",
226
+ ["PyCallable", "PyExternal"],
227
+ ["PyCallable", "PyExternal"],
228
+ {"weight": "integer", "prov": "string[]"},
229
+ ),
230
+ RelType("PY_EXTENDS", ["PyClass"], ["PyClass"]),
231
+ RelType(
232
+ "PY_IMPORTS",
233
+ ["PyModule"],
234
+ ["PyModule", "PyPackage"],
235
+ {"spellings": "string[]", "imported_names": "string[]", "aliases": "string[]"},
236
+ ),
237
+ RelType("PY_DECORATED_BY", ["PyCallable"], ["PyDecorator"]),
238
+ # Level-3 CPG overlay (-a 3 only): the cross-language dataflow vocabulary,
239
+ # PY_-namespaced so per-language SDK backends can scope their queries.
240
+ RelType("PY_HAS_CFG_NODE", ["PyCallable"], ["PyCFGNode"]),
241
+ # ``_k`` is the relationship-identity discriminant (internal, underscore-
242
+ # prefixed like ``_module``): PY_CFG_NEXT merges per ``kind`` (a conditional's
243
+ # true/false pair), PY_DDG per ``(var, prov)`` (one dependence per variable,
244
+ # and the ssa/points-to split) — a plain endpoint-pair MERGE would collapse
245
+ # legitimately-distinct edges.
246
+ RelType("PY_CFG_NEXT", ["PyCFGNode"], ["PyCFGNode"], {"kind": "string", "_k": "string"}),
247
+ RelType("PY_CDG", ["PyCFGNode"], ["PyCFGNode"]),
248
+ RelType("PY_DDG", ["PyCFGNode"], ["PyCFGNode"], {"var": "string", "prov": "string[]", "_k": "string"}),
249
+ RelType("PY_PARAM_IN", ["PyCFGNode"], ["PyCFGNode"], {"var": "string"}),
250
+ RelType("PY_PARAM_OUT", ["PyCFGNode"], ["PyCFGNode"], {"var": "string"}),
251
+ RelType("PY_SUMMARY", ["PyCFGNode"], ["PyCFGNode"]),
252
+ ]
253
+
254
+
255
+ def uniqueness_constraints() -> list[str]:
256
+ """One uniqueness constraint per distinct (merge_label, key)."""
257
+ seen: set[tuple[str, str]] = set()
258
+ out: list[str] = []
259
+
260
+ for node in NODE_LABELS:
261
+ identifier = (node.merge_label, node.key)
262
+ if identifier in seen:
263
+ continue
264
+
265
+ seen.add(identifier)
266
+ out.append(
267
+ f"CREATE CONSTRAINT {node.merge_label.lower()}_{node.key} "
268
+ f"IF NOT EXISTS FOR (x:{node.merge_label}) "
269
+ f"REQUIRE x.{node.key} IS UNIQUE"
270
+ )
271
+
272
+ return out
273
+
274
+
275
+ CONSTRAINTS: List[str] = uniqueness_constraints()
276
+
35
277
  INDEXES: List[str] = [
36
278
  "CREATE INDEX py_callable_name IF NOT EXISTS FOR (c:PyCallable) ON (c.name)",
37
279
  "CREATE INDEX py_class_name IF NOT EXISTS FOR (c:PyClass) ON (c.name)",
38
280
  "CREATE FULLTEXT INDEX py_code_fts IF NOT EXISTS FOR (c:PyCallable) ON EACH [c.code, c.docstring]",
39
281
  ]
282
+
283
+
284
+ @dataclass
285
+ class SchemaDocument:
286
+ schema_version: str
287
+ generator: str
288
+ marker_labels: List[str]
289
+ node_labels: List[NodeLabel]
290
+ relationship_types: List[RelType]
291
+ constraints: List[str]
292
+ indexes: List[str]
293
+
294
+
295
+ def build_schema_document() -> dict:
296
+ """Build the full machine-readable schema document emitted by ``--emit schema``."""
297
+ return {
298
+ "schema_version": SCHEMA_VERSION,
299
+ "generator": "codeanalyzer-python",
300
+ "marker_labels": list(MARKER_LABELS),
301
+ "node_labels": [
302
+ {
303
+ "label": n.label,
304
+ "merge_label": n.merge_label,
305
+ "key": n.key,
306
+ "properties": n.properties,
307
+ }
308
+ for n in NODE_LABELS
309
+ ],
310
+ "relationship_types": [
311
+ {
312
+ "type": r.type,
313
+ "from": r.from_labels,
314
+ "to": r.to_labels,
315
+ "properties": r.properties,
316
+ }
317
+ for r in REL_TYPES
318
+ ],
319
+ "constraints": list(CONSTRAINTS),
320
+ "indexes": list(INDEXES),
321
+ }
@@ -49,6 +49,10 @@ class AnalysisOptions:
49
49
  neo4j_password: str = "neo4j"
50
50
  neo4j_database: Optional[str] = None
51
51
  analysis_level: int = 1
52
+ # Level-3 dataflow knobs: which program graphs to emit (csv of
53
+ # cfg|dfg|pdg|sdg) and the access-path k-limit.
54
+ graphs: str = "cfg,dfg,pdg,sdg"
55
+ graph_field_depth: int = 3
52
56
  using_ray: bool = False
53
57
  rebuild_analysis: bool = False
54
58
  skip_tests: bool = True
@@ -0,0 +1,61 @@
1
+ """Provenance capture: repository (git) and analyzer identity for a snapshot.
2
+
3
+ Runs git only as a subprocess query against the analyzed project directory —
4
+ never mutates anything. Absence of git (no repo, no binary) degrades to None.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import subprocess
9
+ from importlib.metadata import PackageNotFoundError, version as _pkg_version
10
+ from pathlib import Path
11
+ from typing import Optional, Union
12
+ from urllib.parse import urlsplit, urlunsplit
13
+
14
+ from codeanalyzer.schema.py_schema import PyAnalyzerInfo, PyRepositoryInfo
15
+
16
+
17
+ def _strip_userinfo(uri: str) -> str:
18
+ """Drop credentials from URL-style remotes; scp-style git@host:path is a
19
+ username, not a secret, and parses with no netloc — left intact."""
20
+ parts = urlsplit(uri)
21
+ if parts.netloc and "@" in parts.netloc:
22
+ return urlunsplit(parts._replace(netloc=parts.netloc.rpartition("@")[2]))
23
+ return uri
24
+
25
+
26
+ def _git(project_dir: Union[Path, str], *args: str) -> Optional[str]:
27
+ """One git query; None on any failure (no repo, no git, timeout)."""
28
+ try:
29
+ result = subprocess.run(
30
+ ["git", "-C", str(project_dir), *args],
31
+ capture_output=True, text=True, timeout=10, check=False,
32
+ )
33
+ except (OSError, subprocess.TimeoutExpired):
34
+ return None
35
+ if result.returncode != 0:
36
+ return None
37
+ return result.stdout.strip()
38
+
39
+
40
+ def repository_info(project_dir: Union[Path, str]) -> Optional[PyRepositoryInfo]:
41
+ """Git provenance of ``project_dir``, or None when it isn't a git checkout."""
42
+ revision = _git(project_dir, "rev-parse", "HEAD")
43
+ if not revision:
44
+ return None
45
+ raw_uri = _git(project_dir, "remote", "get-url", "origin")
46
+ uri = _strip_userinfo(raw_uri) if raw_uri else None
47
+ status = _git(project_dir, "status", "--porcelain", "--untracked-files=no")
48
+ return PyRepositoryInfo(uri=uri, revision=revision, dirty=bool(status))
49
+
50
+
51
+ def analyzer_info(analysis_level: int) -> PyAnalyzerInfo:
52
+ """Identity + configuration of this analyzer run."""
53
+ try:
54
+ version = _pkg_version("codeanalyzer-python")
55
+ except PackageNotFoundError:
56
+ version = "unknown"
57
+ return PyAnalyzerInfo(
58
+ name="codeanalyzer-python",
59
+ version=version,
60
+ config={"analysis_level": analysis_level},
61
+ )
@@ -2,6 +2,12 @@ from importlib.metadata import version, PackageNotFoundError
2
2
  from packaging.version import parse as parse_version
3
3
 
4
4
  from .py_schema import (
5
+ Analysis,
6
+ BodyNode,
7
+ CdgEdge,
8
+ CfgEdge,
9
+ DdgEdge,
10
+ ParamEdge,
5
11
  PyApplication,
6
12
  PyCallable,
7
13
  PyCallableParameter,
@@ -12,9 +18,12 @@ from .py_schema import (
12
18
  PyImport,
13
19
  PyModule,
14
20
  PyVariableDeclaration,
21
+ Span,
22
+ SummaryEdge,
15
23
  )
16
24
 
17
25
  __all__ = [
26
+ "Analysis",
18
27
  "PyApplication",
19
28
  "PyExternalSymbol",
20
29
  "PyImport",
@@ -25,6 +34,13 @@ __all__ = [
25
34
  "PyCallable",
26
35
  "PyClassAttribute",
27
36
  "PyCallableParameter",
37
+ "Span",
38
+ "BodyNode",
39
+ "CfgEdge",
40
+ "CdgEdge",
41
+ "DdgEdge",
42
+ "SummaryEdge",
43
+ "ParamEdge",
28
44
  ]
29
45
 
30
46
  try:
@@ -44,6 +60,7 @@ if not PYDANTIC_V2:
44
60
  PyClass=PyClass,
45
61
  PyModule=PyModule
46
62
  )
63
+ Analysis.update_forward_refs(PyApplication=PyApplication)
47
64
 
48
65
  # Compatibility helpers for Pydantic v1/v2
49
66
  def model_dump_json(model, **kwargs):
@@ -55,6 +72,8 @@ def model_dump_json(model, **kwargs):
55
72
  v1_kwargs = {}
56
73
  if 'indent' in kwargs:
57
74
  v1_kwargs['indent'] = kwargs['indent']
75
+ if 'exclude_none' in kwargs:
76
+ v1_kwargs['exclude_none'] = kwargs['exclude_none']
58
77
  if 'separators' in kwargs:
59
78
  # In v1, separators is passed to dumps_kwargs
60
79
  v1_kwargs['separators'] = kwargs['separators']
@@ -0,0 +1,37 @@
1
+ """Walk the symbol-table tree and stamp every node with its can:// id."""
2
+ from __future__ import annotations
3
+ from typing import Dict
4
+ from codeanalyzer.schema import ids
5
+ from codeanalyzer.schema.py_schema import PyApplication, PyModule, PyClass, PyCallable
6
+
7
+
8
+ def assign_ids(app: PyApplication, app_name: str) -> Dict[str, str]:
9
+ """Sets `.id` on the app + every module/class/callable. Returns a
10
+ `signature -> can://id` map for later stages (identity layer input)."""
11
+ app.id = ids.application_id(app_name); app.kind = "application"
12
+ sig_to_id: Dict[str, str] = {}
13
+
14
+ def do_callable(parent_id: str, c: PyCallable) -> None:
15
+ seg = ids.callable_sig_segment(c.name, [p.name for p in c.parameters])
16
+ c.id = ids.child_id(parent_id, seg)
17
+ sig_to_id[c.signature] = c.id
18
+ for ic in (c.callables or {}).values():
19
+ do_callable(c.id, ic)
20
+ for icl in (c.types or {}).values():
21
+ do_class(c.id, icl)
22
+
23
+ def do_class(parent_id: str, cl: PyClass) -> None:
24
+ cl.id = ids.child_id(parent_id, cl.name); cl.kind = "class"
25
+ sig_to_id[cl.signature] = cl.id
26
+ for m in (cl.callables or {}).values():
27
+ do_callable(cl.id, m)
28
+ for ic in (cl.types or {}).values():
29
+ do_class(cl.id, ic)
30
+
31
+ for file_key, mod in app.symbol_table.items():
32
+ mod.id = ids.module_id(app_name, file_key); mod.kind = "module"
33
+ for fn in (mod.functions or {}).values():
34
+ do_callable(mod.id, fn)
35
+ for cl in (mod.types or {}).values():
36
+ do_class(mod.id, cl)
37
+ return sig_to_id
@@ -0,0 +1,12 @@
1
+ """Re-identify call-graph edge endpoints onto canonical can:// ids so the JSON
2
+ call_graph agrees with the Neo4j PY_CALLS projection. Declared endpoints map
3
+ through sig_to_id; external/library endpoints keep their dotted signature
4
+ (they have no can:// id)."""
5
+ from __future__ import annotations
6
+ from codeanalyzer.schema.py_schema import PyApplication
7
+
8
+
9
+ def reidentify_call_graph(app: PyApplication, sig_to_id: dict) -> None:
10
+ for edge in app.call_graph or []:
11
+ edge.src = sig_to_id.get(edge.src, edge.src)
12
+ edge.dst = sig_to_id.get(edge.dst, edge.dst)
@@ -0,0 +1,23 @@
1
+ """Canonical `can://` id construction for schema v2 (durable ids, ≥ callable).
2
+ Ordinal ids (< callable) are `ordinal_id(callable_id, tag)`. Pure functions;
3
+ ids are opaque handles (the <file> segment itself contains '/')."""
4
+ from __future__ import annotations
5
+ from typing import List
6
+
7
+ _SCHEME = "can://python"
8
+
9
+ def application_id(app_name: str) -> str:
10
+ return f"{_SCHEME}/{app_name}"
11
+
12
+ def module_id(app_name: str, file_key: str) -> str:
13
+ rel = file_key.replace("\\", "/").lstrip("./")
14
+ return f"{application_id(app_name)}/{rel}"
15
+
16
+ def child_id(parent_id: str, segment: str) -> str:
17
+ return f"{parent_id}/{segment}"
18
+
19
+ def callable_sig_segment(name: str, param_names: List[str]) -> str:
20
+ return f"{name}({','.join(param_names)})"
21
+
22
+ def ordinal_id(callable_id: str, tag: str) -> str:
23
+ return f"{callable_id}@{tag}"
@@ -0,0 +1,29 @@
1
+ """L1 body population: materialize `call` nodes from existing call sites.
2
+ `callee` is left None here — the sanctioned null→id refinement happens at L2."""
3
+ from __future__ import annotations
4
+ from codeanalyzer.schema.py_schema import PyApplication, PyClass, PyCallable, BodyNode, Span, byte_offsets
5
+
6
+ def _do_callable(source: str, c: PyCallable) -> None:
7
+ for cs in c.call_sites or []:
8
+ key = f"{cs.start_line}:{cs.start_column}"
9
+ span = Span(start=(cs.start_line, cs.start_column),
10
+ end=(cs.end_line, cs.end_column),
11
+ bytes=byte_offsets(source, cs.start_line, cs.start_column, cs.end_line, cs.end_column)) if source else None
12
+ c.body[key] = BodyNode(kind="call", span=span, callee=None)
13
+ for ic in (c.callables or {}).values():
14
+ _do_callable(source, ic)
15
+ for icl in (c.types or {}).values():
16
+ _do_class(source, icl)
17
+
18
+ def _do_class(source: str, cl: PyClass) -> None:
19
+ for m in (cl.callables or {}).values():
20
+ _do_callable(source, m)
21
+ for ic in (cl.types or {}).values():
22
+ _do_class(source, ic)
23
+
24
+ def populate_l1_body(app: PyApplication) -> None:
25
+ for mod in app.symbol_table.values():
26
+ for fn in (mod.functions or {}).values():
27
+ _do_callable(mod.source, fn)
28
+ for cl in (mod.types or {}).values():
29
+ _do_class(mod.source, cl)
@@ -0,0 +1,36 @@
1
+ """L2 refinement: fill each L1 `call` body node's `callee` (null→id) from the
2
+ call site's resolved signature — the one sanctioned value change. A declared
3
+ target becomes its can:// id; an external/library target keeps its dotted
4
+ signature; an unresolved call site leaves `callee` absent."""
5
+ from __future__ import annotations
6
+ from codeanalyzer.schema.py_schema import PyApplication, PyClass, PyCallable
7
+
8
+
9
+ def _do_callable(c: PyCallable, sig_to_id: dict) -> None:
10
+ for cs in c.call_sites or []:
11
+ if cs.callee_signature is None:
12
+ continue
13
+ key = f"{cs.start_line}:{cs.start_column}"
14
+ node = c.body.get(key)
15
+ if node is None or node.kind != "call":
16
+ continue
17
+ node.callee = sig_to_id.get(cs.callee_signature, cs.callee_signature)
18
+ for ic in (c.callables or {}).values():
19
+ _do_callable(ic, sig_to_id)
20
+ for icl in (c.types or {}).values():
21
+ _do_class(icl, sig_to_id)
22
+
23
+
24
+ def _do_class(cl: PyClass, sig_to_id: dict) -> None:
25
+ for m in (cl.callables or {}).values():
26
+ _do_callable(m, sig_to_id)
27
+ for ic in (cl.types or {}).values():
28
+ _do_class(ic, sig_to_id)
29
+
30
+
31
+ def backfill_callees(app: PyApplication, sig_to_id: dict) -> None:
32
+ for mod in app.symbol_table.values():
33
+ for fn in (mod.functions or {}).values():
34
+ _do_callable(fn, sig_to_id)
35
+ for cl in (mod.types or {}).values():
36
+ _do_class(cl, sig_to_id)