codeanalyzer-python 0.2.1__py3-none-any.whl → 0.3.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.
- codeanalyzer/__main__.py +90 -6
- codeanalyzer/core.py +105 -210
- codeanalyzer/options/__init__.py +2 -2
- codeanalyzer/options/options.py +20 -1
- codeanalyzer/schema/py_schema.py +1 -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/utils/logging.py +5 -2
- codeanalyzer/utils/progress_bar.py +15 -7
- codeanalyzer_python-0.3.0.dist-info/METADATA +550 -0
- {codeanalyzer_python-0.2.1.dist-info → codeanalyzer_python-0.3.0.dist-info}/RECORD +18 -19
- 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 → codeanalyzer_python-0.3.0.dist-info}/WHEEL +0 -0
- {codeanalyzer_python-0.2.1.dist-info → codeanalyzer_python-0.3.0.dist-info}/entry_points.txt +0 -0
- {codeanalyzer_python-0.2.1.dist-info → codeanalyzer_python-0.3.0.dist-info}/licenses/LICENSE +0 -0
- {codeanalyzer_python-0.2.1.dist-info → codeanalyzer_python-0.3.0.dist-info}/licenses/NOTICE +0 -0
codeanalyzer/__main__.py
CHANGED
|
@@ -7,7 +7,7 @@ from codeanalyzer.core import Codeanalyzer
|
|
|
7
7
|
from codeanalyzer.utils import _set_log_level, logger
|
|
8
8
|
from codeanalyzer.config import OutputFormat
|
|
9
9
|
from codeanalyzer.schema import model_dump_json
|
|
10
|
-
from codeanalyzer.options import AnalysisOptions, EmitTarget
|
|
10
|
+
from codeanalyzer.options import AnalysisOptions, EmitTarget, ShardStrategy
|
|
11
11
|
|
|
12
12
|
|
|
13
13
|
def main(
|
|
@@ -83,9 +83,16 @@ def main(
|
|
|
83
83
|
help="Neo4j database name (default: server default). [env: NEO4J_DATABASE]",
|
|
84
84
|
),
|
|
85
85
|
] = None,
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
86
|
+
analysis_level: Annotated[
|
|
87
|
+
int,
|
|
88
|
+
typer.Option(
|
|
89
|
+
"-a",
|
|
90
|
+
"--analysis-level",
|
|
91
|
+
help="Analysis depth: 1=symbol table+Jedi call graph, 2=+PyCG call graph.",
|
|
92
|
+
min=1,
|
|
93
|
+
max=2,
|
|
94
|
+
),
|
|
95
|
+
] = 1,
|
|
89
96
|
using_ray: Annotated[
|
|
90
97
|
bool,
|
|
91
98
|
typer.Option("--ray/--no-ray", help="Enable Ray for distributed analysis."),
|
|
@@ -137,6 +144,78 @@ def main(
|
|
|
137
144
|
verbosity: Annotated[
|
|
138
145
|
int, typer.Option("-v", count=True, help="Increase verbosity: -v, -vv, -vvv")
|
|
139
146
|
] = 0,
|
|
147
|
+
pycg_shard: Annotated[
|
|
148
|
+
bool,
|
|
149
|
+
typer.Option(
|
|
150
|
+
"--pycg-shard/--no-pycg-shard",
|
|
151
|
+
help=(
|
|
152
|
+
"Shard PyCG call-graph analysis by Python package (level 2 only). "
|
|
153
|
+
"When the project exceeds the 500-file ceiling, PyCG is run "
|
|
154
|
+
"independently per top-level package with cross-package imports "
|
|
155
|
+
"treated as ghost nodes. Without this flag, projects over the "
|
|
156
|
+
"ceiling fall back to Jedi-only edges."
|
|
157
|
+
),
|
|
158
|
+
),
|
|
159
|
+
] = False,
|
|
160
|
+
pycg_shard_ceiling: Annotated[
|
|
161
|
+
int,
|
|
162
|
+
typer.Option(
|
|
163
|
+
"--pycg-shard-ceiling",
|
|
164
|
+
help=(
|
|
165
|
+
"Maximum files per shard when --pycg-shard is active (default 100). "
|
|
166
|
+
"Shards exceeding this limit are skipped; their call edges are "
|
|
167
|
+
"omitted from the call graph (Jedi edges for those packages are "
|
|
168
|
+
"still included). Lower values are safer for packages with deep "
|
|
169
|
+
"class hierarchies or heavy import graphs."
|
|
170
|
+
),
|
|
171
|
+
min=1,
|
|
172
|
+
),
|
|
173
|
+
] = 100,
|
|
174
|
+
pycg_shard_timeout: Annotated[
|
|
175
|
+
int,
|
|
176
|
+
typer.Option(
|
|
177
|
+
"--pycg-shard-timeout",
|
|
178
|
+
help=(
|
|
179
|
+
"Per-shard wall-clock timeout in seconds when --pycg-shard is "
|
|
180
|
+
"active (default 120). A shard that exceeds this limit is skipped "
|
|
181
|
+
"gracefully. PyCG's fixpoint is bimodal: it either converges "
|
|
182
|
+
"quickly or diverges indefinitely, so the timeout acts as a final "
|
|
183
|
+
"safety net after the file-count ceiling. Set to 0 to disable. "
|
|
184
|
+
"POSIX only (macOS / Linux); ignored on Windows."
|
|
185
|
+
),
|
|
186
|
+
min=0,
|
|
187
|
+
),
|
|
188
|
+
] = 120,
|
|
189
|
+
pycg_shard_strategy: Annotated[
|
|
190
|
+
ShardStrategy,
|
|
191
|
+
typer.Option(
|
|
192
|
+
"--pycg-shard-strategy",
|
|
193
|
+
help=(
|
|
194
|
+
"How --pycg-shard groups files (level 2 only). 'jedi' (default) "
|
|
195
|
+
"partitions the Jedi module-dependency graph (SCC + Louvain) so "
|
|
196
|
+
"tightly-coupled modules co-compute and few call edges are "
|
|
197
|
+
"severed between shards; import cycles are never split. "
|
|
198
|
+
"'package' uses the legacy one-shard-per-package-directory "
|
|
199
|
+
"grouping."
|
|
200
|
+
),
|
|
201
|
+
),
|
|
202
|
+
] = ShardStrategy.JEDI,
|
|
203
|
+
pycg_max_iter: Annotated[
|
|
204
|
+
int,
|
|
205
|
+
typer.Option(
|
|
206
|
+
"--pycg-max-iter",
|
|
207
|
+
help=(
|
|
208
|
+
"Cap on PyCG's fixpoint passes per shard/project (level 2; "
|
|
209
|
+
"default 50). PyCG iterates until its points-to state stops "
|
|
210
|
+
"changing, but its access-path domain has no convergence bound, "
|
|
211
|
+
"so heavy metaclass/mixin code (e.g. an ORM) can loop with each "
|
|
212
|
+
"pass costing seconds. The cap returns a sound-but-incomplete "
|
|
213
|
+
"call graph instead of looping until the timeout kills it. "
|
|
214
|
+
"Set to -1 for PyCG's unbounded run-to-convergence behaviour."
|
|
215
|
+
),
|
|
216
|
+
min=-1,
|
|
217
|
+
),
|
|
218
|
+
] = 50,
|
|
140
219
|
):
|
|
141
220
|
options = AnalysisOptions(
|
|
142
221
|
input=input,
|
|
@@ -148,7 +227,7 @@ def main(
|
|
|
148
227
|
neo4j_user=neo4j_user,
|
|
149
228
|
neo4j_password=neo4j_password,
|
|
150
229
|
neo4j_database=neo4j_database,
|
|
151
|
-
|
|
230
|
+
analysis_level=analysis_level,
|
|
152
231
|
using_ray=using_ray,
|
|
153
232
|
rebuild_analysis=rebuild_analysis,
|
|
154
233
|
skip_tests=skip_tests,
|
|
@@ -157,6 +236,11 @@ def main(
|
|
|
157
236
|
cache_dir=cache_dir,
|
|
158
237
|
clear_cache=clear_cache,
|
|
159
238
|
verbosity=verbosity,
|
|
239
|
+
pycg_shard=pycg_shard,
|
|
240
|
+
pycg_shard_ceiling=pycg_shard_ceiling,
|
|
241
|
+
pycg_shard_timeout=pycg_shard_timeout,
|
|
242
|
+
pycg_shard_strategy=pycg_shard_strategy,
|
|
243
|
+
pycg_max_iter=pycg_max_iter,
|
|
160
244
|
)
|
|
161
245
|
|
|
162
246
|
_set_log_level(options.verbosity)
|
|
@@ -230,7 +314,7 @@ def _write_output(artifacts, output_dir: Path, format: OutputFormat):
|
|
|
230
314
|
app = typer.Typer(
|
|
231
315
|
callback=main,
|
|
232
316
|
name="canpy",
|
|
233
|
-
help="Static Analysis on Python source code using Jedi,
|
|
317
|
+
help="Static Analysis on Python source code using Jedi, PyCG and Tree sitter.",
|
|
234
318
|
invoke_without_command=True,
|
|
235
319
|
no_args_is_help=True,
|
|
236
320
|
add_completion=False,
|
codeanalyzer/core.py
CHANGED
|
@@ -6,6 +6,8 @@ import sys
|
|
|
6
6
|
from pathlib import Path
|
|
7
7
|
from typing import Any, Dict, Optional, Union, List
|
|
8
8
|
|
|
9
|
+
import time
|
|
10
|
+
|
|
9
11
|
import ray
|
|
10
12
|
from codeanalyzer.utils import logger
|
|
11
13
|
from codeanalyzer.schema import (
|
|
@@ -17,13 +19,12 @@ from codeanalyzer.schema import (
|
|
|
17
19
|
)
|
|
18
20
|
from codeanalyzer.schema.py_schema import PyCallEdge
|
|
19
21
|
from codeanalyzer.semantic_analysis.call_graph import (
|
|
22
|
+
filter_external_edges,
|
|
20
23
|
jedi_call_graph_edges,
|
|
21
24
|
merge_edges,
|
|
22
25
|
resolve_unresolved_constructors,
|
|
23
26
|
)
|
|
24
|
-
from codeanalyzer.semantic_analysis.
|
|
25
|
-
from codeanalyzer.semantic_analysis.codeql.codeql_analysis import CodeQL
|
|
26
|
-
from codeanalyzer.semantic_analysis.codeql.codeql_exceptions import CodeQLExceptions
|
|
27
|
+
from codeanalyzer.semantic_analysis.pycg import PyCG, PyCGExceptions
|
|
27
28
|
from codeanalyzer.syntactic_analysis.exceptions import SymbolTableBuilderRayError
|
|
28
29
|
from codeanalyzer.syntactic_analysis.symbol_table_builder import SymbolTableBuilder
|
|
29
30
|
from codeanalyzer.utils import ProgressBar
|
|
@@ -54,7 +55,7 @@ def _process_file_with_ray(py_file: Union[Path, str], project_dir: Union[Path, s
|
|
|
54
55
|
|
|
55
56
|
|
|
56
57
|
class Codeanalyzer:
|
|
57
|
-
"""Core
|
|
58
|
+
"""Core static analysis engine for Python projects.
|
|
58
59
|
|
|
59
60
|
Args:
|
|
60
61
|
options (AnalysisOptions): Analysis configuration options containing all necessary parameters.
|
|
@@ -64,16 +65,13 @@ class Codeanalyzer:
|
|
|
64
65
|
self.options = options
|
|
65
66
|
self.project_dir = Path(options.input).resolve()
|
|
66
67
|
self.skip_tests = options.skip_tests
|
|
67
|
-
self.
|
|
68
|
+
self.analysis_level = options.analysis_level
|
|
68
69
|
self.rebuild_analysis = options.rebuild_analysis
|
|
69
70
|
self.no_venv = options.no_venv
|
|
70
71
|
self.cache_dir = (
|
|
71
72
|
options.cache_dir.resolve() if options.cache_dir is not None else self.project_dir
|
|
72
73
|
) / ".codeanalyzer"
|
|
73
74
|
self.clear_cache = options.clear_cache
|
|
74
|
-
self.db_path: Optional[Path] = None
|
|
75
|
-
self.codeql_bin: Optional[Path] = None
|
|
76
|
-
self.codeql_packs_dir: Optional[Path] = None
|
|
77
75
|
self.virtualenv: Optional[Path] = None
|
|
78
76
|
self.using_ray: bool = options.using_ray
|
|
79
77
|
self.file_name: Optional[Path] = options.file_name
|
|
@@ -85,6 +83,7 @@ class Codeanalyzer:
|
|
|
85
83
|
capture_output: bool = True,
|
|
86
84
|
check: bool = True,
|
|
87
85
|
suppress_output: bool = False,
|
|
86
|
+
log_on_failure: bool = True,
|
|
88
87
|
) -> subprocess.CompletedProcess:
|
|
89
88
|
"""
|
|
90
89
|
Runs a subprocess with real-time output streaming to the logger.
|
|
@@ -94,7 +93,10 @@ class Codeanalyzer:
|
|
|
94
93
|
cwd: Working directory to run the command in.
|
|
95
94
|
capture_output: If True, retains and returns the output.
|
|
96
95
|
check: If True, raises CalledProcessError on non-zero exit.
|
|
97
|
-
suppress_output: If True, silences
|
|
96
|
+
suppress_output: If True, silences per-line debug output.
|
|
97
|
+
log_on_failure: If False, suppresses the error-level log on
|
|
98
|
+
non-zero exit (use when the caller handles the exception and
|
|
99
|
+
will emit its own diagnostic).
|
|
98
100
|
|
|
99
101
|
Returns:
|
|
100
102
|
subprocess.CompletedProcess
|
|
@@ -125,9 +127,10 @@ class Codeanalyzer:
|
|
|
125
127
|
|
|
126
128
|
if check and returncode != 0:
|
|
127
129
|
error_output = "\n".join(output_lines)
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
130
|
+
if log_on_failure:
|
|
131
|
+
logger.error(f"Command failed with exit code {returncode}: {' '.join(cmd)}")
|
|
132
|
+
if error_output:
|
|
133
|
+
logger.error(f"Command output:\n{error_output}")
|
|
131
134
|
raise subprocess.CalledProcessError(returncode, cmd, output=error_output)
|
|
132
135
|
|
|
133
136
|
return subprocess.CompletedProcess(
|
|
@@ -235,26 +238,36 @@ class Codeanalyzer:
|
|
|
235
238
|
|
|
236
239
|
@staticmethod
|
|
237
240
|
def _uv_bin() -> Optional[str]:
|
|
238
|
-
"""Path to
|
|
239
|
-
dependency, so
|
|
240
|
-
|
|
241
|
+
"""Path to the uv binary bundled with the ``uv`` PyPI package (a declared
|
|
242
|
+
dependency, so always present in our install -- including inside a Docker
|
|
243
|
+
image). We deliberately ignore any uv on PATH so the analyzer always uses
|
|
244
|
+
the pinned, vendored uv. Returns ``None`` only if the package is somehow
|
|
245
|
+
missing (callers fall back to pip)."""
|
|
241
246
|
try:
|
|
242
247
|
from uv import find_uv_bin
|
|
243
|
-
|
|
244
248
|
return str(find_uv_bin())
|
|
245
249
|
except Exception:
|
|
246
|
-
return
|
|
250
|
+
return None
|
|
247
251
|
|
|
248
252
|
def _install_into_venv(self, venv_python: Path, args: List[str]) -> None:
|
|
249
253
|
"""Install packages into the target venv, preferring uv for speed (parallel
|
|
250
254
|
downloads + a shared global cache) and falling back to the venv's own pip
|
|
251
|
-
when uv is unavailable.
|
|
255
|
+
when uv is unavailable.
|
|
256
|
+
|
|
257
|
+
Raises ``subprocess.CalledProcessError`` on failure; callers in
|
|
258
|
+
``__enter__`` catch this and warn-and-continue so a single failing
|
|
259
|
+
package (e.g. a C extension that needs system libs) does not abort the
|
|
260
|
+
entire analysis.
|
|
261
|
+
"""
|
|
252
262
|
uv = self._uv_bin()
|
|
253
263
|
if uv:
|
|
254
264
|
cmd = [uv, "pip", "install", "--python", str(venv_python), *args]
|
|
255
265
|
else:
|
|
256
266
|
cmd = [str(venv_python), "-m", "pip", "install", *args]
|
|
257
|
-
self._cmd_exec_helper(
|
|
267
|
+
self._cmd_exec_helper(
|
|
268
|
+
cmd, cwd=self.project_dir, check=True,
|
|
269
|
+
suppress_output=True, log_on_failure=False,
|
|
270
|
+
)
|
|
258
271
|
|
|
259
272
|
def __enter__(self) -> "Codeanalyzer":
|
|
260
273
|
# If no virtualenv is provided, try to create one using requirements.txt or pyproject.toml
|
|
@@ -287,21 +300,32 @@ class Codeanalyzer:
|
|
|
287
300
|
for dep_file, _ in dependency_files:
|
|
288
301
|
if (self.project_dir / dep_file).exists():
|
|
289
302
|
logger.info(f"Installing dependencies from {dep_file}")
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
303
|
+
try:
|
|
304
|
+
self._install_into_venv(
|
|
305
|
+
venv_python,
|
|
306
|
+
["--upgrade", "-r", str(self.project_dir / dep_file)],
|
|
307
|
+
)
|
|
308
|
+
except subprocess.CalledProcessError as exc:
|
|
309
|
+
logger.warning(
|
|
310
|
+
f"Dependency installation from {dep_file} failed "
|
|
311
|
+
f"(exit {exc.returncode}) — continuing without it. "
|
|
312
|
+
"Jedi type resolution may be incomplete."
|
|
313
|
+
)
|
|
294
314
|
|
|
295
315
|
# Handle Pipenv files
|
|
296
316
|
if (self.project_dir / "Pipfile").exists():
|
|
297
317
|
logger.info("Installing dependencies from Pipfile")
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
318
|
+
try:
|
|
319
|
+
self._install_into_venv(venv_python, ["pipenv"])
|
|
320
|
+
self._cmd_exec_helper(
|
|
321
|
+
["pipenv", "install", "--dev"],
|
|
322
|
+
cwd=self.project_dir,
|
|
323
|
+
check=True,
|
|
324
|
+
)
|
|
325
|
+
except subprocess.CalledProcessError as exc:
|
|
326
|
+
logger.warning(
|
|
327
|
+
f"Pipenv installation failed (exit {exc.returncode}) — continuing without it."
|
|
328
|
+
)
|
|
305
329
|
|
|
306
330
|
# Handle conda environment files
|
|
307
331
|
conda_files = ["conda.yml", "environment.yml"]
|
|
@@ -319,7 +343,13 @@ class Codeanalyzer:
|
|
|
319
343
|
|
|
320
344
|
if any((self.project_dir / file).exists() for file in package_definition_files):
|
|
321
345
|
logger.info("Installing project in editable mode")
|
|
322
|
-
|
|
346
|
+
try:
|
|
347
|
+
self._install_into_venv(venv_python, ["-e", str(self.project_dir)])
|
|
348
|
+
except subprocess.CalledProcessError as exc:
|
|
349
|
+
logger.warning(
|
|
350
|
+
f"Editable install failed (exit {exc.returncode}) — "
|
|
351
|
+
"continuing without it. Jedi type resolution may be incomplete."
|
|
352
|
+
)
|
|
323
353
|
else:
|
|
324
354
|
logger.warning("No package definition files found, skipping editable installation")
|
|
325
355
|
|
|
@@ -331,60 +361,6 @@ class Codeanalyzer:
|
|
|
331
361
|
if not self.no_venv and venv_path.exists():
|
|
332
362
|
self.virtualenv = venv_path
|
|
333
363
|
|
|
334
|
-
if self.using_codeql:
|
|
335
|
-
logger.info(f"(Re-)initializing CodeQL analysis for {self.project_dir}")
|
|
336
|
-
|
|
337
|
-
# Resolve the CLI binary before anything else uses it: DB build
|
|
338
|
-
# below needs it, and so does every subsequent query run.
|
|
339
|
-
self.codeql_bin = self._ensure_codeql_bin()
|
|
340
|
-
# Download the standard query library pack (idempotent). The
|
|
341
|
-
# CLI install ships only the language extractors; the
|
|
342
|
-
# ``codeql/python-all`` library pack must be fetched separately.
|
|
343
|
-
self.codeql_packs_dir = self._ensure_codeql_packs(self.codeql_bin)
|
|
344
|
-
|
|
345
|
-
cache_root = self.cache_dir / "codeql"
|
|
346
|
-
cache_root.mkdir(parents=True, exist_ok=True)
|
|
347
|
-
self.db_path = cache_root / f"{self.project_dir.name}-db"
|
|
348
|
-
self.db_path.mkdir(exist_ok=True)
|
|
349
|
-
|
|
350
|
-
checksum_file = self.db_path / ".checksum"
|
|
351
|
-
current_checksum = self._compute_checksum(self.project_dir)
|
|
352
|
-
|
|
353
|
-
def is_cache_valid() -> bool:
|
|
354
|
-
if not (self.db_path / "db-python").exists():
|
|
355
|
-
return False
|
|
356
|
-
if not checksum_file.exists():
|
|
357
|
-
return False
|
|
358
|
-
return checksum_file.read_text().strip() == current_checksum
|
|
359
|
-
|
|
360
|
-
if self.rebuild_analysis or not is_cache_valid():
|
|
361
|
-
logger.info("Creating new CodeQL database...")
|
|
362
|
-
|
|
363
|
-
cmd = [
|
|
364
|
-
str(self.codeql_bin),
|
|
365
|
-
"database",
|
|
366
|
-
"create",
|
|
367
|
-
str(self.db_path),
|
|
368
|
-
f"--source-root={self.project_dir}",
|
|
369
|
-
"--language=python",
|
|
370
|
-
"--overwrite",
|
|
371
|
-
]
|
|
372
|
-
|
|
373
|
-
proc = subprocess.Popen(
|
|
374
|
-
cmd, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE
|
|
375
|
-
)
|
|
376
|
-
_, err = proc.communicate()
|
|
377
|
-
|
|
378
|
-
if proc.returncode != 0:
|
|
379
|
-
raise CodeQLExceptions.CodeQLDatabaseBuildException(
|
|
380
|
-
f"Error building CodeQL database:\n{err.decode()}"
|
|
381
|
-
)
|
|
382
|
-
|
|
383
|
-
checksum_file.write_text(current_checksum)
|
|
384
|
-
|
|
385
|
-
else:
|
|
386
|
-
logger.info(f"Reusing cached CodeQL DB at {self.db_path}")
|
|
387
|
-
|
|
388
364
|
return self
|
|
389
365
|
|
|
390
366
|
def __exit__(self, *args, **kwargs) -> None:
|
|
@@ -449,24 +425,21 @@ class Codeanalyzer:
|
|
|
449
425
|
# Build symbol table from cached application if available (if no available, the build a new one)
|
|
450
426
|
symbol_table = self._build_symbol_table(cached_pyapplication.symbol_table if cached_pyapplication else {})
|
|
451
427
|
|
|
452
|
-
# Build the call graph in four steps:
|
|
453
|
-
# 1. Run CodeQL (when enabled). Produces resolved edges with
|
|
454
|
-
# ``provenance=["codeql"]`` and augments ``PyCallsite``s
|
|
455
|
-
# in-place — filling ``callee_signature`` for sites Jedi
|
|
456
|
-
# couldn't resolve.
|
|
457
|
-
# 2. Heuristic fallback for constructor calls neither Jedi nor
|
|
458
|
-
# CodeQL could resolve (commonly classes nested inside
|
|
459
|
-
# functions). Walks the symbol table by class short-name +
|
|
460
|
-
# scope and writes ``<class>.__init__`` into the site.
|
|
461
|
-
# 3. Derive Jedi edges from the now-fully-augmented symbol
|
|
462
|
-
# table — these reflect every resolution the symbol table
|
|
463
|
-
# contains, regardless of which pass put it there.
|
|
464
|
-
# 4. Merge with CodeQL edges; provenance unions for edges both
|
|
465
|
-
# backends saw.
|
|
466
|
-
codeql_edges = self._get_call_graph(symbol_table, augment_sites=True)
|
|
467
428
|
resolve_unresolved_constructors(symbol_table)
|
|
429
|
+
|
|
430
|
+
# Level 1: Jedi call graph.
|
|
431
|
+
t0_jedi = time.perf_counter()
|
|
468
432
|
jedi_edges = jedi_call_graph_edges(symbol_table)
|
|
469
|
-
call_graph =
|
|
433
|
+
call_graph = list(jedi_edges)
|
|
434
|
+
logger.info("✅ Jedi: %d edges in %.1fs", len(call_graph), time.perf_counter() - t0_jedi)
|
|
435
|
+
|
|
436
|
+
if self.analysis_level >= 2:
|
|
437
|
+
# Level 2: also add PyCG edges. The Jedi edges double as the
|
|
438
|
+
# coupling graph that drives coupling-aware PyCG sharding.
|
|
439
|
+
pycg_edges = self._get_pycg_call_graph(symbol_table, jedi_edges)
|
|
440
|
+
call_graph = merge_edges(call_graph, pycg_edges)
|
|
441
|
+
|
|
442
|
+
call_graph = filter_external_edges(call_graph, symbol_table)
|
|
470
443
|
|
|
471
444
|
# Classify call-graph endpoints that are not declared in the symbol table
|
|
472
445
|
# (imported library / builtin members) once, so the JSON and Neo4j backends
|
|
@@ -573,7 +546,8 @@ class Codeanalyzer:
|
|
|
573
546
|
Dict[str, PyModule]: A dictionary mapping file paths to PyModule objects.
|
|
574
547
|
"""
|
|
575
548
|
symbol_table: Dict[str, PyModule] = {}
|
|
576
|
-
|
|
549
|
+
t0_st = time.perf_counter()
|
|
550
|
+
|
|
577
551
|
# Handle single file analysis
|
|
578
552
|
if self.file_name is not None:
|
|
579
553
|
single_file = self.project_dir / self.file_name
|
|
@@ -680,123 +654,44 @@ class Codeanalyzer:
|
|
|
680
654
|
if files_from_cache > 0:
|
|
681
655
|
logger.info(f"Reused {files_from_cache} files from cache, processed {files_processed} new/changed files")
|
|
682
656
|
|
|
683
|
-
logger.info(
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
def _ensure_codeql_packs(self, codeql_bin: Path) -> Path:
|
|
687
|
-
"""Materialize a qlpack that depends on ``codeql/python-all``.
|
|
688
|
-
|
|
689
|
-
The CodeQL CLI install ships only the language extractors — query
|
|
690
|
-
library packs (and their transitive dependencies like
|
|
691
|
-
``codeql/concepts``) must be resolved separately. The canonical
|
|
692
|
-
way is to declare the dependency in a ``qlpack.yml`` and run
|
|
693
|
-
``codeql pack install`` in that directory; CodeQL writes a
|
|
694
|
-
``codeql-pack.lock.yml`` and downloads everything needed.
|
|
695
|
-
|
|
696
|
-
We do this once per project under ``<cache_dir>/codeql/qlpack/``
|
|
697
|
-
and return that directory. The query runner then writes its
|
|
698
|
-
temporary ``.ql`` file inside this pack — colocation makes
|
|
699
|
-
``import python`` resolve without any ``--additional-packs`` or
|
|
700
|
-
``--search-path`` gymnastics.
|
|
701
|
-
"""
|
|
702
|
-
pack_dir = self.cache_dir / "codeql" / "qlpack"
|
|
703
|
-
pack_dir.mkdir(parents=True, exist_ok=True)
|
|
704
|
-
qlpack_yml = pack_dir / "qlpack.yml"
|
|
705
|
-
lock_file = pack_dir / "codeql-pack.lock.yml"
|
|
706
|
-
|
|
707
|
-
if not qlpack_yml.exists():
|
|
708
|
-
qlpack_yml.write_text(
|
|
709
|
-
"name: codeanalyzer-deps\n"
|
|
710
|
-
"version: 1.0.0\n"
|
|
711
|
-
"dependencies:\n"
|
|
712
|
-
' codeql/python-all: "*"\n'
|
|
713
|
-
)
|
|
714
|
-
|
|
715
|
-
if lock_file.exists():
|
|
716
|
-
logger.debug(f"CodeQL pack dependencies already installed in {pack_dir}")
|
|
717
|
-
return pack_dir
|
|
718
|
-
|
|
719
|
-
logger.info(f"Installing CodeQL pack dependencies in {pack_dir}.")
|
|
720
|
-
proc = subprocess.Popen(
|
|
721
|
-
[str(codeql_bin), "pack", "install", str(pack_dir)],
|
|
722
|
-
stdout=subprocess.PIPE,
|
|
723
|
-
stderr=subprocess.PIPE,
|
|
724
|
-
)
|
|
725
|
-
_, err = proc.communicate()
|
|
726
|
-
if proc.returncode != 0:
|
|
727
|
-
raise CodeQLExceptions.CodeQLDatabaseBuildException(
|
|
728
|
-
f"Failed to install CodeQL pack dependencies:\n"
|
|
729
|
-
f"{(err or b'').decode(errors='replace')}"
|
|
730
|
-
)
|
|
731
|
-
return pack_dir
|
|
732
|
-
|
|
733
|
-
def _ensure_codeql_bin(self) -> Path:
|
|
734
|
-
"""Locate (or download) the CodeQL CLI binary into the project cache.
|
|
735
|
-
|
|
736
|
-
Resolution order:
|
|
737
|
-
1. An existing binary inside ``<cache_dir>/codeql/bin/`` —
|
|
738
|
-
reused across runs on the same project.
|
|
739
|
-
2. ``codeql`` already on the user's PATH — picked up verbatim.
|
|
740
|
-
3. Otherwise, download into ``<cache_dir>/codeql/bin/``.
|
|
741
|
-
|
|
742
|
-
The project-local cache is preferred over PATH so the version we
|
|
743
|
-
installed earlier wins over whatever the OS ships — keeps behavior
|
|
744
|
-
deterministic when the user has both.
|
|
745
|
-
"""
|
|
746
|
-
bin_root = self.cache_dir / "codeql" / "bin"
|
|
747
|
-
bin_root.mkdir(parents=True, exist_ok=True)
|
|
748
|
-
|
|
749
|
-
existing = next(
|
|
750
|
-
(p for p in bin_root.rglob("codeql") if p.is_file()),
|
|
751
|
-
None,
|
|
657
|
+
logger.info(
|
|
658
|
+
"✅ Symbol table: %d modules in %.1fs",
|
|
659
|
+
len(symbol_table), time.perf_counter() - t0_st,
|
|
752
660
|
)
|
|
753
|
-
|
|
754
|
-
logger.debug(f"Reusing cached CodeQL CLI at {existing}")
|
|
755
|
-
return existing.resolve()
|
|
756
|
-
|
|
757
|
-
on_path = shutil.which("codeql")
|
|
758
|
-
if on_path:
|
|
759
|
-
logger.debug(f"Using CodeQL CLI from PATH at {on_path}")
|
|
760
|
-
return Path(on_path)
|
|
761
|
-
|
|
762
|
-
logger.info(f"CodeQL CLI not found; downloading into {bin_root}.")
|
|
763
|
-
downloaded = CodeQLLoader.download_and_extract_codeql(bin_root)
|
|
764
|
-
if not downloaded.exists() or not os.access(downloaded, os.X_OK):
|
|
765
|
-
raise FileNotFoundError(
|
|
766
|
-
f"CodeQL binary not executable after download: {downloaded}"
|
|
767
|
-
)
|
|
768
|
-
return downloaded
|
|
661
|
+
return symbol_table
|
|
769
662
|
|
|
770
|
-
def
|
|
663
|
+
def _get_pycg_call_graph(
|
|
771
664
|
self,
|
|
772
665
|
symbol_table: Dict[str, PyModule],
|
|
773
|
-
|
|
666
|
+
jedi_edges: List[PyCallEdge],
|
|
774
667
|
) -> List[PyCallEdge]:
|
|
775
|
-
"""Build
|
|
668
|
+
"""Build PyCG-resolved call edges.
|
|
776
669
|
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
670
|
+
Runs PyCG's iterative name-pointer analysis over the whole project
|
|
671
|
+
and returns edges with ``provenance=["pycg"]``. Falls back to an
|
|
672
|
+
empty list and logs a warning on any failure so the caller can
|
|
673
|
+
continue with Jedi-only edges.
|
|
780
674
|
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
CodeQL query is shared (cached on the ``CodeQL`` instance) so
|
|
785
|
-
this costs no extra DB work.
|
|
675
|
+
*jedi_edges* are the level-1 call edges; under the ``jedi`` shard
|
|
676
|
+
strategy they drive coupling-aware partitioning (see
|
|
677
|
+
:func:`shard_planner.plan_shards`).
|
|
786
678
|
"""
|
|
787
|
-
if not self.using_codeql or self.db_path is None:
|
|
788
|
-
return []
|
|
789
679
|
try:
|
|
790
|
-
|
|
680
|
+
pycg = PyCG(
|
|
791
681
|
self.project_dir,
|
|
792
|
-
self.
|
|
793
|
-
|
|
794
|
-
|
|
682
|
+
skip_tests=self.skip_tests,
|
|
683
|
+
shard=self.options.pycg_shard,
|
|
684
|
+
shard_ceiling=self.options.pycg_shard_ceiling,
|
|
685
|
+
shard_timeout=self.options.pycg_shard_timeout,
|
|
686
|
+
shard_strategy=self.options.pycg_shard_strategy,
|
|
687
|
+
max_iter=self.options.pycg_max_iter,
|
|
688
|
+
using_ray=self.using_ray,
|
|
795
689
|
)
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
return
|
|
800
|
-
except
|
|
801
|
-
logger.warning(f"
|
|
690
|
+
return pycg.build_call_graph_edges(symbol_table, jedi_edges=jedi_edges)
|
|
691
|
+
except PyCGExceptions.PyCGImportError as exc:
|
|
692
|
+
logger.warning(f"PyCG not installed — level 2 edges will be Jedi-only: {exc}")
|
|
693
|
+
return []
|
|
694
|
+
except PyCGExceptions.PyCGAnalysisError as exc:
|
|
695
|
+
logger.warning(f"PyCG analysis failed — level 2 edges will be Jedi-only: {exc}")
|
|
696
|
+
logger.debug("PyCG full traceback:", exc_info=True)
|
|
802
697
|
return []
|
codeanalyzer/options/__init__.py
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
from .options import AnalysisOptions, EmitTarget, OutputFormat
|
|
1
|
+
from .options import AnalysisOptions, EmitTarget, OutputFormat, ShardStrategy
|
|
2
2
|
|
|
3
|
-
__all__ = ["AnalysisOptions", "EmitTarget", "OutputFormat"]
|
|
3
|
+
__all__ = ["AnalysisOptions", "EmitTarget", "OutputFormat", "ShardStrategy"]
|
codeanalyzer/options/options.py
CHANGED
|
@@ -23,6 +23,20 @@ class EmitTarget(str, Enum):
|
|
|
23
23
|
SCHEMA = "schema"
|
|
24
24
|
|
|
25
25
|
|
|
26
|
+
class ShardStrategy(str, Enum):
|
|
27
|
+
"""How ``--pycg-shard`` groups files into shards (level 2 only).
|
|
28
|
+
|
|
29
|
+
- ``jedi`` : partition the Jedi module-dependency graph (strongly-
|
|
30
|
+
connected-component condensation + Louvain) so tightly-
|
|
31
|
+
coupled modules co-compute and few call edges are severed
|
|
32
|
+
between shards. Import cycles are never split.
|
|
33
|
+
- ``package`` : legacy one-shard-per-package-directory grouping.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
JEDI = "jedi"
|
|
37
|
+
PACKAGE = "package"
|
|
38
|
+
|
|
39
|
+
|
|
26
40
|
@dataclass
|
|
27
41
|
class AnalysisOptions:
|
|
28
42
|
input: Path
|
|
@@ -34,7 +48,7 @@ class AnalysisOptions:
|
|
|
34
48
|
neo4j_user: str = "neo4j"
|
|
35
49
|
neo4j_password: str = "neo4j"
|
|
36
50
|
neo4j_database: Optional[str] = None
|
|
37
|
-
|
|
51
|
+
analysis_level: int = 1
|
|
38
52
|
using_ray: bool = False
|
|
39
53
|
rebuild_analysis: bool = False
|
|
40
54
|
skip_tests: bool = True
|
|
@@ -43,3 +57,8 @@ class AnalysisOptions:
|
|
|
43
57
|
cache_dir: Optional[Path] = None
|
|
44
58
|
clear_cache: bool = False
|
|
45
59
|
verbosity: int = 0
|
|
60
|
+
pycg_shard: bool = False
|
|
61
|
+
pycg_shard_ceiling: int = 100
|
|
62
|
+
pycg_shard_timeout: int = 120
|
|
63
|
+
pycg_shard_strategy: ShardStrategy = ShardStrategy.JEDI
|
|
64
|
+
pycg_max_iter: int = 50
|
codeanalyzer/schema/py_schema.py
CHANGED
|
@@ -355,7 +355,7 @@ class PyCallEdge(BaseModel):
|
|
|
355
355
|
target: str # callee's PyCallable.signature
|
|
356
356
|
type: Literal["CALL_DEP"] = "CALL_DEP"
|
|
357
357
|
weight: int = 1
|
|
358
|
-
provenance: List[Literal["jedi", "
|
|
358
|
+
provenance: List[Literal["jedi", "pycg", "joern"]] = []
|
|
359
359
|
|
|
360
360
|
|
|
361
361
|
@builder
|