codeanalyzer-python 0.2.1__py3-none-any.whl → 0.3.1__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.
- codeanalyzer/__main__.py +116 -6
- codeanalyzer/core.py +139 -213
- codeanalyzer/neo4j/__init__.py +1 -1
- codeanalyzer/neo4j/emit.py +2 -2
- codeanalyzer/neo4j/project.py +84 -18
- codeanalyzer/neo4j/schema.py +261 -15
- codeanalyzer/options/__init__.py +2 -2
- codeanalyzer/options/options.py +20 -1
- codeanalyzer/provenance.py +61 -0
- codeanalyzer/schema/py_schema.py +39 -1
- codeanalyzer/semantic_analysis/call_graph.py +24 -3
- codeanalyzer/semantic_analysis/pycg/__init__.py +20 -0
- codeanalyzer/semantic_analysis/pycg/pycg_analysis.py +1054 -0
- codeanalyzer/semantic_analysis/{codeql/__init__.py → pycg/pycg_exceptions.py} +5 -8
- codeanalyzer/semantic_analysis/pycg/shard_planner.py +401 -0
- codeanalyzer/syntactic_analysis/import_resolver.py +67 -0
- codeanalyzer/syntactic_analysis/symbol_table_builder.py +74 -10
- codeanalyzer/utils/logging.py +5 -2
- codeanalyzer/utils/progress_bar.py +15 -7
- codeanalyzer_python-0.3.1.dist-info/METADATA +553 -0
- codeanalyzer_python-0.3.1.dist-info/RECORD +39 -0
- {codeanalyzer_python-0.2.1.dist-info → codeanalyzer_python-0.3.1.dist-info}/WHEEL +1 -1
- codeanalyzer/neo4j/catalog.py +0 -245
- codeanalyzer/semantic_analysis/codeql/codeql_analysis.py +0 -382
- codeanalyzer/semantic_analysis/codeql/codeql_exceptions.py +0 -12
- codeanalyzer/semantic_analysis/codeql/codeql_loader.py +0 -91
- codeanalyzer/semantic_analysis/codeql/codeql_query_runner.py +0 -185
- codeanalyzer_python-0.2.1.dist-info/METADATA +0 -415
- codeanalyzer_python-0.2.1.dist-info/RECORD +0 -39
- {codeanalyzer_python-0.2.1.dist-info → codeanalyzer_python-0.3.1.dist-info}/entry_points.txt +0 -0
- {codeanalyzer_python-0.2.1.dist-info → codeanalyzer_python-0.3.1.dist-info}/licenses/LICENSE +0 -0
- {codeanalyzer_python-0.2.1.dist-info → codeanalyzer_python-0.3.1.dist-info}/licenses/NOTICE +0 -0
codeanalyzer/neo4j/catalog.py
DELETED
|
@@ -1,245 +0,0 @@
|
|
|
1
|
-
################################################################################
|
|
2
|
-
# Copyright IBM Corporation 2025
|
|
3
|
-
#
|
|
4
|
-
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
-
# you may not use this file except in compliance with the License.
|
|
6
|
-
# You may obtain a copy of the License at
|
|
7
|
-
#
|
|
8
|
-
# http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
-
#
|
|
10
|
-
# Unless required by applicable law or agreed to in writing, software
|
|
11
|
-
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
-
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
-
# See the License for the specific language governing permissions and
|
|
14
|
-
# limitations under the License.
|
|
15
|
-
################################################################################
|
|
16
|
-
|
|
17
|
-
"""The declarative Neo4j schema catalog — the single in-repo source of truth for
|
|
18
|
-
the graph contract (node labels, their keys and typed properties, relationship
|
|
19
|
-
types and their endpoints). ``--emit schema`` serializes this (with the DDL from
|
|
20
|
-
:mod:`codeanalyzer.neo4j.schema`) to a machine-readable ``schema.json``, and the
|
|
21
|
-
conformance test (``test/test_neo4j_schema.py``) asserts the real emitter never
|
|
22
|
-
produces a label / relationship / property that isn't declared here — so this
|
|
23
|
-
file cannot silently drift from :mod:`codeanalyzer.neo4j.project`.
|
|
24
|
-
|
|
25
|
-
SCHEMA_VERSION is the contract version: bump MAJOR on a breaking change
|
|
26
|
-
(renamed/removed label, relationship or key), MINOR on an additive change (new
|
|
27
|
-
label/rel/property). It is stamped onto the ``:PyApplication`` node of every
|
|
28
|
-
emitted graph so any consumer can detect a producer/consumer mismatch at runtime.
|
|
29
|
-
"""
|
|
30
|
-
from __future__ import annotations
|
|
31
|
-
|
|
32
|
-
from dataclasses import dataclass, field
|
|
33
|
-
from typing import Dict, List
|
|
34
|
-
|
|
35
|
-
from codeanalyzer.neo4j.schema import CONSTRAINTS, INDEXES
|
|
36
|
-
|
|
37
|
-
SCHEMA_VERSION = "1.1.0"
|
|
38
|
-
|
|
39
|
-
# PropType ∈ {"string", "integer", "float", "boolean", "string[]", "integer[]"}.
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
@dataclass
|
|
43
|
-
class NodeLabel:
|
|
44
|
-
label: str # the specific label (also the catalog key)
|
|
45
|
-
merge_label: str # the label the uniqueness constraint / MERGE is on
|
|
46
|
-
key: str
|
|
47
|
-
properties: Dict[str, str]
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
@dataclass
|
|
51
|
-
class RelType:
|
|
52
|
-
type: str
|
|
53
|
-
from_labels: List[str]
|
|
54
|
-
to_labels: List[str]
|
|
55
|
-
properties: Dict[str, str] = field(default_factory=dict)
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
# Labels layered onto a node in addition to its primary/specific label.
|
|
59
|
-
MARKER_LABELS: List[str] = []
|
|
60
|
-
|
|
61
|
-
_SPAN = {"start_line": "integer", "end_line": "integer"}
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
NODE_LABELS: List[NodeLabel] = [
|
|
65
|
-
NodeLabel(
|
|
66
|
-
"PyApplication",
|
|
67
|
-
"PyApplication",
|
|
68
|
-
"name",
|
|
69
|
-
{"name": "string", "schema_version": "string"},
|
|
70
|
-
),
|
|
71
|
-
NodeLabel(
|
|
72
|
-
"PyModule",
|
|
73
|
-
"PyModule",
|
|
74
|
-
"file_key",
|
|
75
|
-
{
|
|
76
|
-
"file_key": "string",
|
|
77
|
-
"module_name": "string",
|
|
78
|
-
"content_hash": "string",
|
|
79
|
-
"last_modified": "float",
|
|
80
|
-
"file_size": "integer",
|
|
81
|
-
"_module": "string",
|
|
82
|
-
},
|
|
83
|
-
),
|
|
84
|
-
NodeLabel(
|
|
85
|
-
"PyClass",
|
|
86
|
-
"PySymbol",
|
|
87
|
-
"signature",
|
|
88
|
-
{
|
|
89
|
-
"signature": "string",
|
|
90
|
-
"name": "string",
|
|
91
|
-
"code": "string",
|
|
92
|
-
"base_classes": "string[]",
|
|
93
|
-
"docstring": "string",
|
|
94
|
-
**_SPAN,
|
|
95
|
-
"_module": "string",
|
|
96
|
-
},
|
|
97
|
-
),
|
|
98
|
-
NodeLabel(
|
|
99
|
-
"PyCallable",
|
|
100
|
-
"PySymbol",
|
|
101
|
-
"signature",
|
|
102
|
-
{
|
|
103
|
-
"signature": "string",
|
|
104
|
-
"name": "string",
|
|
105
|
-
"path": "string",
|
|
106
|
-
"return_type": "string",
|
|
107
|
-
"cyclomatic_complexity": "integer",
|
|
108
|
-
"code": "string",
|
|
109
|
-
"code_start_line": "integer",
|
|
110
|
-
**_SPAN,
|
|
111
|
-
"docstring": "string",
|
|
112
|
-
"decorators": "string[]",
|
|
113
|
-
"parameters_json": "string",
|
|
114
|
-
"accessed_symbols_json": "string",
|
|
115
|
-
"_module": "string",
|
|
116
|
-
},
|
|
117
|
-
),
|
|
118
|
-
NodeLabel(
|
|
119
|
-
"PyExternal",
|
|
120
|
-
"PySymbol",
|
|
121
|
-
"signature",
|
|
122
|
-
{"signature": "string", "name": "string", "module": "string"},
|
|
123
|
-
),
|
|
124
|
-
NodeLabel("PyPackage", "PyPackage", "name", {"name": "string"}),
|
|
125
|
-
NodeLabel(
|
|
126
|
-
"PyDecorator",
|
|
127
|
-
"PyDecorator",
|
|
128
|
-
"name",
|
|
129
|
-
{"name": "string"},
|
|
130
|
-
),
|
|
131
|
-
NodeLabel(
|
|
132
|
-
"PyCallSite",
|
|
133
|
-
"PyCallSite",
|
|
134
|
-
"id",
|
|
135
|
-
{
|
|
136
|
-
"id": "string",
|
|
137
|
-
"method_name": "string",
|
|
138
|
-
"receiver_expr": "string",
|
|
139
|
-
"receiver_type": "string",
|
|
140
|
-
"argument_types": "string[]",
|
|
141
|
-
"return_type": "string",
|
|
142
|
-
"callee_signature": "string",
|
|
143
|
-
"is_constructor_call": "boolean",
|
|
144
|
-
"start_line": "integer",
|
|
145
|
-
"start_column": "integer",
|
|
146
|
-
"end_line": "integer",
|
|
147
|
-
"end_column": "integer",
|
|
148
|
-
"_module": "string",
|
|
149
|
-
},
|
|
150
|
-
),
|
|
151
|
-
NodeLabel(
|
|
152
|
-
"PyAttribute",
|
|
153
|
-
"PyAttribute",
|
|
154
|
-
"id",
|
|
155
|
-
{
|
|
156
|
-
"id": "string",
|
|
157
|
-
"name": "string",
|
|
158
|
-
"type": "string",
|
|
159
|
-
"docstring": "string",
|
|
160
|
-
**_SPAN,
|
|
161
|
-
"_module": "string",
|
|
162
|
-
},
|
|
163
|
-
),
|
|
164
|
-
NodeLabel(
|
|
165
|
-
"PyVariable",
|
|
166
|
-
"PyVariable",
|
|
167
|
-
"id",
|
|
168
|
-
{
|
|
169
|
-
"id": "string",
|
|
170
|
-
"name": "string",
|
|
171
|
-
"type": "string",
|
|
172
|
-
"initializer": "string",
|
|
173
|
-
"scope": "string",
|
|
174
|
-
**_SPAN,
|
|
175
|
-
"_module": "string",
|
|
176
|
-
},
|
|
177
|
-
),
|
|
178
|
-
]
|
|
179
|
-
|
|
180
|
-
_DECL_TARGETS = ["PyClass", "PyCallable"]
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
REL_TYPES: List[RelType] = [
|
|
184
|
-
RelType("PY_HAS_MODULE", ["PyApplication"], ["PyModule"]),
|
|
185
|
-
RelType("PY_DECLARES", ["PyModule", "PyClass", "PyCallable"], _DECL_TARGETS),
|
|
186
|
-
RelType("PY_HAS_METHOD", ["PyClass"], ["PyCallable"]),
|
|
187
|
-
RelType("PY_HAS_ATTRIBUTE", ["PyClass"], ["PyAttribute"]),
|
|
188
|
-
RelType("PY_DECLARES_VAR", ["PyModule", "PyCallable"], ["PyVariable"]),
|
|
189
|
-
RelType("PY_HAS_CALLSITE", ["PyCallable"], ["PyCallSite"]),
|
|
190
|
-
RelType("PY_RESOLVES_TO", ["PyCallSite"], ["PyCallable", "PyExternal"]),
|
|
191
|
-
RelType(
|
|
192
|
-
"PY_CALLS",
|
|
193
|
-
["PyCallable", "PyExternal"],
|
|
194
|
-
["PyCallable", "PyExternal"],
|
|
195
|
-
{"weight": "integer", "provenance": "string[]"},
|
|
196
|
-
),
|
|
197
|
-
RelType("PY_EXTENDS", ["PyClass"], ["PyClass"]),
|
|
198
|
-
RelType(
|
|
199
|
-
"PY_IMPORTS",
|
|
200
|
-
["PyModule"],
|
|
201
|
-
["PyPackage"],
|
|
202
|
-
{"imported_names": "string[]", "aliases": "string[]"},
|
|
203
|
-
),
|
|
204
|
-
RelType("PY_DECORATED_BY", ["PyCallable"], ["PyDecorator"]),
|
|
205
|
-
]
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
@dataclass
|
|
209
|
-
class SchemaDocument:
|
|
210
|
-
schema_version: str
|
|
211
|
-
generator: str
|
|
212
|
-
marker_labels: List[str]
|
|
213
|
-
node_labels: List[NodeLabel]
|
|
214
|
-
relationship_types: List[RelType]
|
|
215
|
-
constraints: List[str]
|
|
216
|
-
indexes: List[str]
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
def build_schema_document() -> dict:
|
|
220
|
-
"""Build the full machine-readable schema document emitted by ``--emit schema``."""
|
|
221
|
-
return {
|
|
222
|
-
"schema_version": SCHEMA_VERSION,
|
|
223
|
-
"generator": "codeanalyzer-python",
|
|
224
|
-
"marker_labels": list(MARKER_LABELS),
|
|
225
|
-
"node_labels": [
|
|
226
|
-
{
|
|
227
|
-
"label": n.label,
|
|
228
|
-
"merge_label": n.merge_label,
|
|
229
|
-
"key": n.key,
|
|
230
|
-
"properties": n.properties,
|
|
231
|
-
}
|
|
232
|
-
for n in NODE_LABELS
|
|
233
|
-
],
|
|
234
|
-
"relationship_types": [
|
|
235
|
-
{
|
|
236
|
-
"type": r.type,
|
|
237
|
-
"from": r.from_labels,
|
|
238
|
-
"to": r.to_labels,
|
|
239
|
-
"properties": r.properties,
|
|
240
|
-
}
|
|
241
|
-
for r in REL_TYPES
|
|
242
|
-
],
|
|
243
|
-
"constraints": list(CONSTRAINTS),
|
|
244
|
-
"indexes": list(INDEXES),
|
|
245
|
-
}
|
|
@@ -1,382 +0,0 @@
|
|
|
1
|
-
################################################################################
|
|
2
|
-
# Copyright IBM Corporation 2025
|
|
3
|
-
#
|
|
4
|
-
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
-
# you may not use this file except in compliance with the License.
|
|
6
|
-
# You may obtain a copy of the License at
|
|
7
|
-
#
|
|
8
|
-
# http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
-
#
|
|
10
|
-
# Unless required by applicable law or agreed to in writing, software
|
|
11
|
-
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
-
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
-
# See the License for the specific language governing permissions and
|
|
14
|
-
# limitations under the License.
|
|
15
|
-
################################################################################
|
|
16
|
-
|
|
17
|
-
"""CodeQL module for analyzing Python code using CodeQL.
|
|
18
|
-
|
|
19
|
-
This module provides functionality to create and manage CodeQL databases
|
|
20
|
-
for Python projects and execute queries against them.
|
|
21
|
-
"""
|
|
22
|
-
|
|
23
|
-
from collections import Counter
|
|
24
|
-
from pathlib import Path
|
|
25
|
-
from typing import Any, Dict, Iterator, List, Tuple, Union
|
|
26
|
-
|
|
27
|
-
from pandas import DataFrame
|
|
28
|
-
|
|
29
|
-
from codeanalyzer.schema.py_schema import PyCallEdge, PyModule
|
|
30
|
-
from codeanalyzer.semantic_analysis.call_graph import iter_callables_in_symbol_table
|
|
31
|
-
from codeanalyzer.semantic_analysis.codeql.codeql_query_runner import CodeQLQueryRunner
|
|
32
|
-
from codeanalyzer.utils import logger
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
class _CallableResolver:
|
|
36
|
-
"""Maps a CodeQL endpoint ``(file, start_line, name, arity)`` to a Jedi
|
|
37
|
-
``PyCallable``.
|
|
38
|
-
|
|
39
|
-
Resolution ladder:
|
|
40
|
-
1. exact ``(abs_path, start_line)`` — the precise join;
|
|
41
|
-
2. on miss, candidates sharing ``(abs_path, short_name)``: a single
|
|
42
|
-
candidate is taken directly; otherwise prefer those whose
|
|
43
|
-
parameter count equals the CodeQL positional arity, then the
|
|
44
|
-
nearest ``start_line``;
|
|
45
|
-
3. no name match -> ``None`` (caller row skipped / callee becomes
|
|
46
|
-
a ghost node).
|
|
47
|
-
|
|
48
|
-
Step 2 recovers edges the ``(file, line)`` join silently drops when
|
|
49
|
-
CodeQL and Jedi disagree on a definition's start line (e.g. decorator
|
|
50
|
-
handling). Jedi's ``parameters`` counts every declared slot (incl.
|
|
51
|
-
``*args``/``**kwargs``/keyword-only) whereas CodeQL's arity is
|
|
52
|
-
positional only, so the arity filter is exact for plain signatures
|
|
53
|
-
and otherwise yields to the nearest-line tiebreak.
|
|
54
|
-
"""
|
|
55
|
-
|
|
56
|
-
def __init__(self) -> None:
|
|
57
|
-
self._by_loc: Dict[Tuple[str, int], Any] = {}
|
|
58
|
-
self._by_name: Dict[Tuple[str, str], List[Any]] = {}
|
|
59
|
-
|
|
60
|
-
@staticmethod
|
|
61
|
-
def _abs(path: str) -> str:
|
|
62
|
-
try:
|
|
63
|
-
return str(Path(path).resolve())
|
|
64
|
-
except (OSError, RuntimeError):
|
|
65
|
-
return path
|
|
66
|
-
|
|
67
|
-
@classmethod
|
|
68
|
-
def from_symbol_table(
|
|
69
|
-
cls, symbol_table: Dict[str, PyModule]
|
|
70
|
-
) -> "_CallableResolver":
|
|
71
|
-
resolver = cls()
|
|
72
|
-
for c in iter_callables_in_symbol_table(symbol_table):
|
|
73
|
-
abs_path = cls._abs(c.path)
|
|
74
|
-
resolver._by_loc[(abs_path, c.start_line)] = c
|
|
75
|
-
resolver._by_name.setdefault((abs_path, c.name), []).append(c)
|
|
76
|
-
return resolver
|
|
77
|
-
|
|
78
|
-
def resolve(
|
|
79
|
-
self, file: str, start_line: int, name: str, arity: int
|
|
80
|
-
) -> Any:
|
|
81
|
-
exact = self._by_loc.get((file, start_line))
|
|
82
|
-
if exact is not None:
|
|
83
|
-
return exact
|
|
84
|
-
if not name:
|
|
85
|
-
return None
|
|
86
|
-
candidates = self._by_name.get((file, name))
|
|
87
|
-
if not candidates:
|
|
88
|
-
return None
|
|
89
|
-
if len(candidates) == 1:
|
|
90
|
-
return candidates[0]
|
|
91
|
-
arity_matched = [c for c in candidates if len(c.parameters) == arity]
|
|
92
|
-
pool = arity_matched or candidates
|
|
93
|
-
return min(pool, key=lambda c: abs(c.start_line - start_line))
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
class CodeQL:
|
|
97
|
-
"""A class for building the application view of a Python application using CodeQL.
|
|
98
|
-
|
|
99
|
-
Args:
|
|
100
|
-
project_dir (str or Path): The path to the root of the Python project.
|
|
101
|
-
|
|
102
|
-
Attributes:
|
|
103
|
-
db_path (Path): The path to the CodeQL database.
|
|
104
|
-
temp_db (TemporaryDirectory or None): The temporary directory object if a temporary database was created.
|
|
105
|
-
"""
|
|
106
|
-
|
|
107
|
-
def __init__(
|
|
108
|
-
self,
|
|
109
|
-
project_dir: Union[str, Path],
|
|
110
|
-
db_path: Path,
|
|
111
|
-
codeql_bin: Union[str, Path, None] = None,
|
|
112
|
-
codeql_packs_dir: Union[str, Path, None] = None,
|
|
113
|
-
) -> None:
|
|
114
|
-
self.project_dir = project_dir
|
|
115
|
-
self.db_path = db_path
|
|
116
|
-
self.codeql_bin = codeql_bin
|
|
117
|
-
self.codeql_packs_dir = codeql_packs_dir
|
|
118
|
-
self._cached_df: "DataFrame | None" = None
|
|
119
|
-
|
|
120
|
-
def _query_call_edges(self) -> DataFrame:
|
|
121
|
-
"""Runs the CodeQL query that emits one row per resolved call site.
|
|
122
|
-
|
|
123
|
-
The query is written against CodeQL's Python library (``import python``).
|
|
124
|
-
It returns physical location handles for both endpoints so the
|
|
125
|
-
downstream post-processor can join into Jedi's existing
|
|
126
|
-
``PyCallable.signature`` space via ``(file_path, start_line)`` —
|
|
127
|
-
no signature normalization required.
|
|
128
|
-
|
|
129
|
-
Filters:
|
|
130
|
-
* Caller must be a ``Function`` (skip module-level / class-body
|
|
131
|
-
calls — they have no ``PyCallable`` to anchor to).
|
|
132
|
-
* Callee may resolve to anything (in-source or library stub);
|
|
133
|
-
non-application callees become **ghost** nodes downstream so
|
|
134
|
-
RPC / third-party / framework edges are preserved.
|
|
135
|
-
|
|
136
|
-
Returns:
|
|
137
|
-
DataFrame: one row per resolved (caller, callee, call-site)
|
|
138
|
-
triple. Duplicate ``(caller_file, caller_start_line,
|
|
139
|
-
callee_file, callee_start_line)`` tuples represent multiple
|
|
140
|
-
call sites in the same caller targeting the same callee and
|
|
141
|
-
are coalesced into a single ``PyCallEdge`` (weight = count)
|
|
142
|
-
by the post-processor.
|
|
143
|
-
"""
|
|
144
|
-
query = [
|
|
145
|
-
"/**",
|
|
146
|
-
" * @name Python call-graph edges",
|
|
147
|
-
" * @description One row per resolved call site: caller, callee,",
|
|
148
|
-
" * and the call-expression location.",
|
|
149
|
-
" * @kind table",
|
|
150
|
-
" * @id py/codeanalyzer/call-graph-edges",
|
|
151
|
-
" */",
|
|
152
|
-
"import python",
|
|
153
|
-
# ``FunctionValue`` / ``ClassValue`` / the ``pointsTo`` predicate
|
|
154
|
-
# live in ObjectAPI, which ``import python`` only brings in as a
|
|
155
|
-
# private import — they aren't re-exported. Pull them in
|
|
156
|
-
# explicitly.
|
|
157
|
-
"import semmle.python.objects.ObjectAPI",
|
|
158
|
-
"",
|
|
159
|
-
# ``Value.getACall()`` is the modern call-resolution API in
|
|
160
|
-
# codeql/python-all 7.x — it returns the ``CallNode`` (CFG)
|
|
161
|
-
# whose target was resolved to that ``Value``. Cleaner than
|
|
162
|
-
# poking at ``pointsTo`` directly.
|
|
163
|
-
# ``callee`` is bound to the FunctionValue's scope so the
|
|
164
|
-
# endpoint emits the same Function-level facts (name, arity,
|
|
165
|
-
# location) the post-processor needs for the name+arity
|
|
166
|
-
# fallback when the (file, start_line) join misses.
|
|
167
|
-
"from CallNode call, Function caller, FunctionValue calleeVal, Function callee",
|
|
168
|
-
"where",
|
|
169
|
-
" call.getScope() = caller and",
|
|
170
|
-
" callee = calleeVal.getScope() and",
|
|
171
|
-
" (",
|
|
172
|
-
# Direct function / bound-method call: foo() or obj.foo()
|
|
173
|
-
" call = calleeVal.getACall()",
|
|
174
|
-
" or",
|
|
175
|
-
# Constructor call: A(...) resolves to a ClassValue; the actual
|
|
176
|
-
# callee is the class's __init__ (via MRO lookup so subclasses
|
|
177
|
-
# without an explicit __init__ still resolve to the inherited one).
|
|
178
|
-
" exists(ClassValue clsVal |",
|
|
179
|
-
" call = clsVal.getACall() and",
|
|
180
|
-
' clsVal.lookup("__init__") = calleeVal',
|
|
181
|
-
" )",
|
|
182
|
-
" )",
|
|
183
|
-
"select",
|
|
184
|
-
# --- Caller endpoint --- (joins to PyCallable: exact by
|
|
185
|
-
# (file, start_line), else by (file, name) + arity)
|
|
186
|
-
" caller.getLocation().getFile().getAbsolutePath(),",
|
|
187
|
-
" caller.getLocation().getStartLine(),",
|
|
188
|
-
" caller.getQualifiedName(),",
|
|
189
|
-
" caller.getName(),",
|
|
190
|
-
" count(caller.getArg(_)),",
|
|
191
|
-
# --- Callee endpoint --- (file/line may live in a library stub;
|
|
192
|
-
# post-processor classifies as in-source or ghost)
|
|
193
|
-
" callee.getLocation().getFile().getAbsolutePath(),",
|
|
194
|
-
" callee.getLocation().getStartLine(),",
|
|
195
|
-
" calleeVal.getQualifiedName(),",
|
|
196
|
-
" callee.getName(),",
|
|
197
|
-
" count(callee.getArg(_)),",
|
|
198
|
-
# --- Call-site location --- (for PyCallsite augmentation)
|
|
199
|
-
" call.getLocation().getStartLine(),",
|
|
200
|
-
" call.getLocation().getStartColumn(),",
|
|
201
|
-
" call.getLocation().getEndLine(),",
|
|
202
|
-
" call.getLocation().getEndColumn()",
|
|
203
|
-
# ``is_constructor`` is derived in the post-processor by
|
|
204
|
-
# checking whether ``callee_qname`` ends in ``.__init__``;
|
|
205
|
-
# avoids QL's restrictive ``if-then-else`` typing here.
|
|
206
|
-
]
|
|
207
|
-
if self._cached_df is not None:
|
|
208
|
-
return self._cached_df
|
|
209
|
-
|
|
210
|
-
query_string = "\n".join(query)
|
|
211
|
-
|
|
212
|
-
with CodeQLQueryRunner(
|
|
213
|
-
self.db_path,
|
|
214
|
-
codeql_bin=self.codeql_bin,
|
|
215
|
-
codeql_packs_dir=self.codeql_packs_dir,
|
|
216
|
-
) as runner:
|
|
217
|
-
df: DataFrame = runner.execute(
|
|
218
|
-
query_string,
|
|
219
|
-
column_names=[
|
|
220
|
-
"caller_file",
|
|
221
|
-
"caller_start_line",
|
|
222
|
-
"caller_qname",
|
|
223
|
-
"caller_name",
|
|
224
|
-
"caller_arity",
|
|
225
|
-
"callee_file",
|
|
226
|
-
"callee_start_line",
|
|
227
|
-
"callee_qname",
|
|
228
|
-
"callee_name",
|
|
229
|
-
"callee_arity",
|
|
230
|
-
"call_start_line",
|
|
231
|
-
"call_start_column",
|
|
232
|
-
"call_end_line",
|
|
233
|
-
"call_end_column",
|
|
234
|
-
],
|
|
235
|
-
)
|
|
236
|
-
self._cached_df = df
|
|
237
|
-
return df
|
|
238
|
-
|
|
239
|
-
@staticmethod
|
|
240
|
-
def _build_callable_resolver(
|
|
241
|
-
symbol_table: Dict[str, PyModule],
|
|
242
|
-
) -> _CallableResolver:
|
|
243
|
-
"""Build the endpoint -> ``PyCallable`` resolver from Jedi.
|
|
244
|
-
|
|
245
|
-
Paths are resolved so they match CodeQL's ``getAbsolutePath()``
|
|
246
|
-
regardless of symlinks or the current working directory.
|
|
247
|
-
"""
|
|
248
|
-
return _CallableResolver.from_symbol_table(symbol_table)
|
|
249
|
-
|
|
250
|
-
def _iter_resolved_rows(
|
|
251
|
-
self, symbol_table: Dict[str, PyModule]
|
|
252
|
-
) -> "Iterator[Tuple[str, str, Any]]":
|
|
253
|
-
"""Yield ``(source_sig, target_sig, row)`` for every CodeQL row.
|
|
254
|
-
|
|
255
|
-
Rows whose caller can't be matched to a ``PyCallable`` in the
|
|
256
|
-
symbol table are skipped. Callee misses fall back to
|
|
257
|
-
``row.callee_qname`` (ghost). Used by both edge construction and
|
|
258
|
-
call-site augmentation so a single CodeQL query feeds both.
|
|
259
|
-
"""
|
|
260
|
-
df = self._query_call_edges()
|
|
261
|
-
if df.empty:
|
|
262
|
-
return
|
|
263
|
-
resolver = self._build_callable_resolver(symbol_table)
|
|
264
|
-
|
|
265
|
-
skipped_unknown_caller = 0
|
|
266
|
-
ghost_callees = 0
|
|
267
|
-
for row in df.itertuples(index=False):
|
|
268
|
-
caller = resolver.resolve(
|
|
269
|
-
row.caller_file,
|
|
270
|
-
int(row.caller_start_line),
|
|
271
|
-
row.caller_name,
|
|
272
|
-
int(row.caller_arity),
|
|
273
|
-
)
|
|
274
|
-
if caller is None:
|
|
275
|
-
skipped_unknown_caller += 1
|
|
276
|
-
continue
|
|
277
|
-
|
|
278
|
-
callee = resolver.resolve(
|
|
279
|
-
row.callee_file,
|
|
280
|
-
int(row.callee_start_line),
|
|
281
|
-
row.callee_name,
|
|
282
|
-
int(row.callee_arity),
|
|
283
|
-
)
|
|
284
|
-
if callee is not None:
|
|
285
|
-
target_sig = callee.signature
|
|
286
|
-
else:
|
|
287
|
-
target_sig = row.callee_qname
|
|
288
|
-
ghost_callees += 1
|
|
289
|
-
|
|
290
|
-
yield caller.signature, target_sig, row
|
|
291
|
-
|
|
292
|
-
if skipped_unknown_caller:
|
|
293
|
-
logger.debug(
|
|
294
|
-
f"CodeQL: skipped {skipped_unknown_caller} rows whose caller "
|
|
295
|
-
f"was not in Jedi's symbol table."
|
|
296
|
-
)
|
|
297
|
-
if ghost_callees:
|
|
298
|
-
logger.debug(
|
|
299
|
-
f"CodeQL: {ghost_callees} rows resolved to ghost (external) callees."
|
|
300
|
-
)
|
|
301
|
-
|
|
302
|
-
def build_call_graph_edges(
|
|
303
|
-
self, symbol_table: Dict[str, PyModule]
|
|
304
|
-
) -> List[PyCallEdge]:
|
|
305
|
-
"""Run the CodeQL query and turn each row into a ``PyCallEdge``.
|
|
306
|
-
|
|
307
|
-
Edges are coalesced on ``(source, target)`` — ``weight`` is the
|
|
308
|
-
number of distinct call sites in the caller targeting the callee.
|
|
309
|
-
Provenance is always ``["codeql"]``; combine with Jedi-derived
|
|
310
|
-
edges via ``call_graph.merge_edges``.
|
|
311
|
-
"""
|
|
312
|
-
edge_counts: Counter = Counter()
|
|
313
|
-
for source_sig, target_sig, _row in self._iter_resolved_rows(symbol_table):
|
|
314
|
-
edge_counts[(source_sig, target_sig)] += 1
|
|
315
|
-
|
|
316
|
-
return [
|
|
317
|
-
PyCallEdge(
|
|
318
|
-
source=src,
|
|
319
|
-
target=dst,
|
|
320
|
-
weight=count,
|
|
321
|
-
provenance=["codeql"],
|
|
322
|
-
)
|
|
323
|
-
for (src, dst), count in edge_counts.items()
|
|
324
|
-
]
|
|
325
|
-
|
|
326
|
-
def augment_call_sites(self, symbol_table: Dict[str, PyModule]) -> int:
|
|
327
|
-
"""Backfill ``PyCallsite.callee_signature`` using CodeQL resolution.
|
|
328
|
-
|
|
329
|
-
Walks every CodeQL row, locates the matching ``PyCallsite`` inside
|
|
330
|
-
the caller's ``PyCallable.call_sites`` by call-expression line range
|
|
331
|
-
(``start_line``, ``end_line``), and fills in ``callee_signature``
|
|
332
|
-
**only when Jedi left it empty**. Existing Jedi-resolved signatures
|
|
333
|
-
are kept (Jedi sees lexical context CodeQL can't, e.g. closures).
|
|
334
|
-
|
|
335
|
-
Match is by line range — column matching is brittle across the two
|
|
336
|
-
tools' 0- vs 1-based conventions. Ambiguity on a single line
|
|
337
|
-
(e.g. ``a.b().c()``) resolves to the first matching site, which is
|
|
338
|
-
an acceptable approximation given how rarely Jedi misses callees
|
|
339
|
-
on chained call lines.
|
|
340
|
-
|
|
341
|
-
Returns:
|
|
342
|
-
Number of ``PyCallsite`` entries augmented.
|
|
343
|
-
"""
|
|
344
|
-
resolver = self._build_callable_resolver(symbol_table)
|
|
345
|
-
df = self._query_call_edges()
|
|
346
|
-
if df.empty:
|
|
347
|
-
return 0
|
|
348
|
-
|
|
349
|
-
augmented = 0
|
|
350
|
-
for row in df.itertuples(index=False):
|
|
351
|
-
caller = resolver.resolve(
|
|
352
|
-
row.caller_file,
|
|
353
|
-
int(row.caller_start_line),
|
|
354
|
-
row.caller_name,
|
|
355
|
-
int(row.caller_arity),
|
|
356
|
-
)
|
|
357
|
-
if caller is None:
|
|
358
|
-
continue
|
|
359
|
-
|
|
360
|
-
callee = resolver.resolve(
|
|
361
|
-
row.callee_file,
|
|
362
|
-
int(row.callee_start_line),
|
|
363
|
-
row.callee_name,
|
|
364
|
-
int(row.callee_arity),
|
|
365
|
-
)
|
|
366
|
-
resolved_sig = callee.signature if callee is not None else row.callee_qname
|
|
367
|
-
|
|
368
|
-
call_start = int(row.call_start_line)
|
|
369
|
-
call_end = int(row.call_end_line)
|
|
370
|
-
for site in caller.call_sites:
|
|
371
|
-
if site.start_line != call_start or site.end_line != call_end:
|
|
372
|
-
continue
|
|
373
|
-
if not site.callee_signature:
|
|
374
|
-
site.callee_signature = resolved_sig
|
|
375
|
-
augmented += 1
|
|
376
|
-
break
|
|
377
|
-
|
|
378
|
-
if augmented:
|
|
379
|
-
logger.debug(
|
|
380
|
-
f"CodeQL: augmented {augmented} PyCallsite.callee_signature entries."
|
|
381
|
-
)
|
|
382
|
-
return augmented
|
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
class CodeQLExceptions:
|
|
2
|
-
class CodeQLDatabaseBuildException(Exception):
|
|
3
|
-
"""Exception raised when there is an error building the CodeQL database."""
|
|
4
|
-
|
|
5
|
-
def __init__(self, message: str) -> None:
|
|
6
|
-
super().__init__(message)
|
|
7
|
-
|
|
8
|
-
class CodeQLQueryExecutionException(Exception):
|
|
9
|
-
"""Exception raised when there is an error building the CodeQL database."""
|
|
10
|
-
|
|
11
|
-
def __init__(self, message: str) -> None:
|
|
12
|
-
super().__init__(message)
|