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 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
- using_codeql: Annotated[
87
- bool, typer.Option("--codeql/--no-codeql", help="Enable CodeQL-based analysis.")
88
- ] = False,
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
- using_codeql=using_codeql,
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, CodeQL and Tree sitter.",
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.codeql import CodeQLLoader
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 functionality for CodeQL analysis.
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.using_codeql = options.using_codeql
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 log output.
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
- logger.error(f"Command failed with exit code {returncode}: {' '.join(cmd)}")
129
- if error_output:
130
- logger.error(f"Command output:\n{error_output}")
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 a uv binary: the one bundled with the ``uv`` PyPI package (a
239
- dependency, so normally always present -- including inside a Docker image),
240
- else a uv on PATH, else ``None`` (callers fall back to pip)."""
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 shutil.which("uv")
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(cmd, cwd=self.project_dir, check=True)
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
- self._install_into_venv(
291
- venv_python,
292
- ["--upgrade", "-r", str(self.project_dir / dep_file)],
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
- # Note: This would require pipenv to be installed
299
- self._install_into_venv(venv_python, ["pipenv"])
300
- self._cmd_exec_helper(
301
- ["pipenv", "install", "--dev"],
302
- cwd=self.project_dir,
303
- check=True,
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
- self._install_into_venv(venv_python, ["-e", str(self.project_dir)])
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 = merge_edges(jedi_edges, codeql_edges)
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("✅ Symbol table generation complete.")
684
- return symbol_table
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
- if existing and os.access(existing, os.X_OK):
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 _get_call_graph(
663
+ def _get_pycg_call_graph(
771
664
  self,
772
665
  symbol_table: Dict[str, PyModule],
773
- augment_sites: bool = False,
666
+ jedi_edges: List[PyCallEdge],
774
667
  ) -> List[PyCallEdge]:
775
- """Build CodeQL-resolved call edges and optionally augment sites.
668
+ """Build PyCG-resolved call edges.
776
669
 
777
- Returns an empty list when CodeQL isn't enabled or the database
778
- isn't available. Edges carry ``provenance=["codeql"]`` merge
779
- with Jedi-derived edges via ``call_graph.merge_edges``.
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
- When ``augment_sites`` is True, also mutates
782
- ``PyCallable.call_sites`` in the symbol table to backfill
783
- ``callee_signature`` for sites Jedi couldn't resolve. The single
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
- cq = CodeQL(
680
+ pycg = PyCG(
791
681
  self.project_dir,
792
- self.db_path,
793
- codeql_bin=self.codeql_bin,
794
- codeql_packs_dir=self.codeql_packs_dir,
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
- edges = cq.build_call_graph_edges(symbol_table)
797
- if augment_sites:
798
- cq.augment_call_sites(symbol_table)
799
- return edges
800
- except Exception as exc:
801
- logger.warning(f"CodeQL call-graph extraction failed: {exc}")
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 []
@@ -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"]
@@ -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
- using_codeql: bool = False
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
@@ -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", "codeql", "joern"]] = []
358
+ provenance: List[Literal["jedi", "pycg", "joern"]] = []
359
359
 
360
360
 
361
361
  @builder