codeanalyzer-python 1.1.1__py3-none-any.whl → 1.2.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 (37) hide show
  1. codeanalyzer/__main__.py +95 -123
  2. codeanalyzer/core.py +21 -45
  3. codeanalyzer/dataflow/access_paths.py +26 -4
  4. codeanalyzer/dataflow/builder.py +7 -0
  5. codeanalyzer/dataflow/identity.py +1 -1
  6. codeanalyzer/dataflow/pdg.py +7 -2
  7. codeanalyzer/dataflow/scc.py +1 -1
  8. codeanalyzer/entrypoints/__init__.py +3 -0
  9. codeanalyzer/entrypoints/detect.py +124 -0
  10. codeanalyzer/entrypoints/matching.py +182 -0
  11. codeanalyzer/entrypoints/pipeline.py +131 -0
  12. codeanalyzer/entrypoints/rules.py +159 -0
  13. codeanalyzer/entrypoints/rules.yml +88 -0
  14. codeanalyzer/neo4j/bolt.py +1 -1
  15. codeanalyzer/neo4j/project.py +85 -60
  16. codeanalyzer/neo4j/schema.py +35 -34
  17. codeanalyzer/options/__init__.py +2 -2
  18. codeanalyzer/options/options.py +2 -26
  19. codeanalyzer/schema/__init__.py +48 -0
  20. codeanalyzer/schema/l1_body.py +11 -1
  21. codeanalyzer/schema/l2_callees.py +29 -13
  22. codeanalyzer/schema/py_schema.py +95 -103
  23. codeanalyzer/semantic_analysis/call_graph.py +20 -4
  24. codeanalyzer/semantic_analysis/defuse_linker.py +1499 -0
  25. codeanalyzer/syntactic_analysis/symbol_table_builder.py +88 -3
  26. {codeanalyzer_python-1.1.1.dist-info → codeanalyzer_python-1.2.0.dist-info}/METADATA +36 -161
  27. {codeanalyzer_python-1.1.1.dist-info → codeanalyzer_python-1.2.0.dist-info}/RECORD +31 -30
  28. {codeanalyzer_python-1.1.1.dist-info → codeanalyzer_python-1.2.0.dist-info}/WHEEL +1 -1
  29. codeanalyzer/config/__init__.py +0 -3
  30. codeanalyzer/config/config.py +0 -8
  31. codeanalyzer/semantic_analysis/pycg/__init__.py +0 -20
  32. codeanalyzer/semantic_analysis/pycg/pycg_analysis.py +0 -1115
  33. codeanalyzer/semantic_analysis/pycg/pycg_exceptions.py +0 -23
  34. codeanalyzer/semantic_analysis/pycg/shard_planner.py +0 -401
  35. {codeanalyzer_python-1.1.1.dist-info → codeanalyzer_python-1.2.0.dist-info}/entry_points.txt +0 -0
  36. {codeanalyzer_python-1.1.1.dist-info → codeanalyzer_python-1.2.0.dist-info}/licenses/LICENSE +0 -0
  37. {codeanalyzer_python-1.1.1.dist-info → codeanalyzer_python-1.2.0.dist-info}/licenses/NOTICE +0 -0
@@ -1,1115 +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
- """PyCG-based call graph construction for analysis level 2.
18
-
19
- PyCG (Apache-2.0, ICSE 2021) uses iterative inter-procedural name-pointer
20
- analysis to produce a call graph with ~99% precision and ~69% recall on
21
- micro-benchmarks. Its dotted namespace format (``module.Class.method``)
22
- aligns directly with the ``PyCallable.signature`` space used by the symbol
23
- table, so no name translation is needed for in-source callees.
24
-
25
- Callees not found in the symbol table are treated as ghost nodes — the same
26
- convention used by :func:`call_graph.to_digraph`.
27
-
28
- **Sharding** (``shard=True``) runs PyCG independently per Python package
29
- root instead of over the entire project. This keeps each shard under the
30
- 500-file ceiling by bounding PyCG's recursive import-following to the
31
- package boundary. Cross-shard imports become ghost nodes (same quality as
32
- Jedi-only edges for those call sites). Edge names are normalised back to
33
- project-relative dotted paths so they align with the symbol table.
34
- """
35
-
36
- # Python 3.13 compatibility: PyCG installs a custom import hook and calls
37
- # importlib.invalidate_caches() during analysis. In Python 3.13, that call
38
- # triggers lazy loading of importlib.metadata → json → json.decoder, which
39
- # re-enters PyCG's hook before its import graph is ready. Pre-importing
40
- # these modules at import time ensures they're already in sys.modules when
41
- # PyCG's hook is active, preventing the re-entrant ImportManagerError.
42
- import fcntl
43
- import hashlib
44
- import importlib.metadata # noqa: F401
45
- import importlib.util # noqa: F401
46
- import contextlib
47
- import os
48
- import json # noqa: F401
49
- import shutil
50
- import signal
51
- import tempfile
52
- import time
53
-
54
- from collections import Counter, defaultdict
55
- from pathlib import Path
56
- from typing import Any, Dict, Generator, List, Optional, Set, Tuple, Union
57
-
58
-
59
- @contextlib.contextmanager
60
- def _shard_timeout(seconds: int) -> Generator[None, None, None]:
61
- """Context manager that raises ``TimeoutError`` if the body runs longer than *seconds*.
62
-
63
- Uses SIGALRM on POSIX (macOS / Linux). On platforms without SIGALRM
64
- (Windows) the context manager is a no-op — shards can still be bounded
65
- by the file-count ceiling.
66
-
67
- Must be called from the main thread (SIGALRM restriction).
68
- """
69
- if seconds <= 0 or not hasattr(signal, "SIGALRM"):
70
- yield
71
- return
72
-
73
- def _handler(signum: int, frame: object) -> None:
74
- raise TimeoutError(f"shard timed out after {seconds}s")
75
-
76
- old_handler = signal.signal(signal.SIGALRM, _handler)
77
- signal.alarm(seconds)
78
- try:
79
- yield
80
- finally:
81
- signal.alarm(0)
82
- signal.signal(signal.SIGALRM, old_handler)
83
-
84
- from codeanalyzer.schema.py_schema import PyCallEdge, PyModule
85
- from codeanalyzer.semantic_analysis.call_graph import iter_callables_in_symbol_table
86
- from codeanalyzer.semantic_analysis.pycg.pycg_exceptions import PyCGExceptions
87
- from codeanalyzer.semantic_analysis.pycg.shard_planner import plan_shards
88
- from codeanalyzer.utils import ProgressBar, logger
89
-
90
-
91
- def _shard_root_path(files: List[str], project_dir: Path) -> Path:
92
- """Content-derived mini-project root for a shard: same project + same file
93
- set → same path on every run (determinism, issue #99)."""
94
- digest = hashlib.sha1(
95
- "\0".join([str(project_dir), *sorted(files)]).encode("utf-8")
96
- ).hexdigest()[:16]
97
- return Path(tempfile.gettempdir()) / f"canpy_pycg_shard_{digest}"
98
-
99
-
100
- def _materialize_shard_root(
101
- files: List[str],
102
- project_dir: Path,
103
- ) -> Tuple[Path, List[str]]:
104
- """Build a temporary symlink mini-project for a shard; return ``(root, eps)``.
105
-
106
- PyCG bounds its import-following to the ``package`` directory — only
107
- modules whose resolved file lives under that root are followed; everything
108
- else becomes a ghost node (``ImportManager``: ``if self.mod_dir not in
109
- mod.__file__: return``). A coupling-derived shard is an arbitrary set of
110
- files that need not form a directory, so we mirror the project layout into
111
- a temp dir holding symlinks to exactly the shard's files plus the
112
- ``__init__.py`` chain each needs for package resolution. Running PyCG with
113
- this mirror as the package root confines analysis to the shard while
114
- emitting project-relative edge names (so ``prefix=""`` — no rename needed).
115
-
116
- The caller owns the returned *root* and must ``shutil.rmtree`` it.
117
- """
118
- # Deterministic root: PyCG's capped fixpoint (--pycg-max-iter) is
119
- # order-sensitive, and its internal state keys on absolute module paths —
120
- # a random mkdtemp suffix changes those strings every run and shifts the
121
- # iteration frontier, making the emitted edge set vary run-to-run
122
- # (issue #99). Deriving the directory name from the shard's content keeps
123
- # the path (and thus the analysis input) identical across runs. Callers
124
- # that may run concurrently on the same shard serialize on the sidecar
125
- # lock (see _shard_symlink_root).
126
- root = _shard_root_path(files, project_dir)
127
- if root.exists():
128
- shutil.rmtree(root, ignore_errors=True)
129
- root.mkdir(parents=True, exist_ok=True)
130
- entry_points: List[str] = []
131
- linked_inits: Set[Path] = set()
132
- for f in sorted(files):
133
- src = Path(f).resolve()
134
- try:
135
- rel = src.relative_to(project_dir)
136
- except ValueError:
137
- continue # defensively skip files outside the project
138
- dst = root / rel
139
- dst.parent.mkdir(parents=True, exist_ok=True)
140
- if not dst.exists():
141
- dst.symlink_to(src)
142
- entry_points.append(str(dst))
143
-
144
- # Symlink the __init__.py chain from project root down to this file's
145
- # package so PyCG/importlib can resolve the dotted module name. These
146
- # add ~0 analysis cost (usually empty) and keep out-of-shard siblings
147
- # unresolved → ghost nodes.
148
- for i in range(len(rel.parent.parts) + 1):
149
- pkg_rel = Path(*rel.parent.parts[:i])
150
- real_init = project_dir / pkg_rel / "__init__.py"
151
- link_init = root / pkg_rel / "__init__.py"
152
- if real_init.exists() and link_init not in linked_inits:
153
- link_init.parent.mkdir(parents=True, exist_ok=True)
154
- if not link_init.exists():
155
- link_init.symlink_to(real_init.resolve())
156
- linked_inits.add(link_init)
157
- return root, entry_points
158
-
159
-
160
- @contextlib.contextmanager
161
- def _shard_symlink_root(
162
- files: List[str],
163
- project_dir: Path,
164
- ) -> Generator[Tuple[Path, List[str]], None, None]:
165
- """Context-manager wrapper around :func:`_materialize_shard_root`.
166
-
167
- Yields ``(root, entry_points)`` and removes the temp tree on exit.
168
-
169
- The root path is content-derived (determinism, issue #99), so two
170
- concurrent analyses of the same shard — e.g. a test suite and a manual
171
- run on one project — would collide on it (one rmtree's the tree the
172
- other is mid-analysis on). An exclusive flock on a sidecar lockfile
173
- serializes them; distinct projects/shards hash to distinct roots and
174
- never contend.
175
- """
176
- digest_root = _shard_root_path(files, project_dir)
177
- lock_path = digest_root.with_name(digest_root.name + ".lock")
178
- lock_fd = os.open(str(lock_path), os.O_CREAT | os.O_RDWR)
179
- try:
180
- fcntl.flock(lock_fd, fcntl.LOCK_EX)
181
- root, entry_points = _materialize_shard_root(files, project_dir)
182
- try:
183
- yield root, entry_points
184
- finally:
185
- shutil.rmtree(root, ignore_errors=True)
186
- finally:
187
- fcntl.flock(lock_fd, fcntl.LOCK_UN)
188
- os.close(lock_fd)
189
-
190
-
191
- def _pycg_shard_worker(
192
- entry_points: List[str],
193
- package_dir: str,
194
- prefix: str,
195
- max_iter: int = -1,
196
- ) -> List[tuple]:
197
- """Run PyCG on one shard; called in a Ray worker process.
198
-
199
- Returns a list of ``(source, target, weight)`` tuples that the caller
200
- converts to :class:`PyCallEdge` objects. This function is a plain
201
- module-level callable so it can be pickled by Ray without capturing any
202
- class-level state. *max_iter* caps PyCG's fixpoint passes (-1 = unbounded).
203
- """
204
- import importlib
205
- import sys
206
-
207
- # Python 3.13 compatibility pre-imports (mirroring the top-level block).
208
- import importlib.metadata # noqa: F401
209
- import importlib.util # noqa: F401
210
- import json # noqa: F401
211
- from collections import Counter as _WorkerCounter
212
-
213
- CallGraphGenerator = None
214
- for pkg_name in ("pycg", "PyCG"):
215
- try:
216
- mod = importlib.import_module(pkg_name)
217
- sys.modules.setdefault("pycg", mod)
218
- sys.modules.setdefault("PyCG", mod)
219
- pycg_mod = importlib.import_module(f"{pkg_name}.pycg")
220
- CallGraphGenerator = pycg_mod.CallGraphGenerator
221
- break
222
- except ImportError:
223
- continue
224
-
225
- if CallGraphGenerator is None:
226
- raise RuntimeError("pycg is not installed in Ray worker — run `pip install pycg`")
227
-
228
- _apply_pycg_posonly_patch()
229
-
230
- cg = CallGraphGenerator(
231
- entry_points=entry_points,
232
- package=package_dir,
233
- max_iter=max_iter,
234
- operation="call-graph",
235
- )
236
- cg.analyze()
237
-
238
- edge_counts = _WorkerCounter()
239
- for src, dst in cg.output_edges():
240
- if prefix:
241
- src = f"{prefix}.{src}"
242
- dst = f"{prefix}.{dst}"
243
- edge_counts[(src, dst)] += 1
244
-
245
- return [(src, dst, count) for (src, dst), count in edge_counts.items()]
246
-
247
-
248
- def _apply_pycg_posonly_patch() -> None:
249
- """Monkey-patch PyCG's PreProcessor to handle Python 3.8+ positional-only params.
250
-
251
- PyCG's ``_get_fun_defaults`` computes the default-argument start index as
252
- ``len(node.args.args) - len(node.args.defaults)``. In Python 3.8+,
253
- ``node.args.defaults`` covers the LAST ``len(defaults)`` arguments of
254
- ``posonlyargs + args`` combined, not just ``args``. When any positional-
255
- only argument has a default (e.g. ``def f(a=1, b=2, /):``), the start
256
- index becomes too negative, causing ``IndexError: list index out of range``
257
- during PyCG's pre-processing pass.
258
-
259
- This function replaces ``PreProcessor._get_fun_defaults`` with a corrected
260
- implementation the first time it is called. Subsequent calls are no-ops.
261
- """
262
- try:
263
- import sys
264
- preprocessor_mod = sys.modules.get("pycg.processing.preprocessor") \
265
- or sys.modules.get("PyCG.processing.preprocessor")
266
- if preprocessor_mod is None:
267
- import importlib
268
- for pkg_name in ("pycg", "PyCG"):
269
- try:
270
- preprocessor_mod = importlib.import_module(
271
- f"{pkg_name}.processing.preprocessor"
272
- )
273
- break
274
- except ImportError:
275
- continue
276
- if preprocessor_mod is None:
277
- return
278
-
279
- PreProcessor = preprocessor_mod.PreProcessor
280
- if getattr(PreProcessor, "_posonly_patched", False):
281
- return
282
-
283
- def _patched_get_fun_defaults(self, node): # type: ignore[override]
284
- defaults = {}
285
- # Combine posonlyargs (Python 3.8+) with regular args so that the
286
- # start index is computed over the full positional parameter list.
287
- all_args = getattr(node.args, "posonlyargs", []) + node.args.args
288
- start = len(all_args) - len(node.args.defaults)
289
- for cnt, d in enumerate(node.args.defaults, start=start):
290
- if not d:
291
- continue
292
- self.visit(d)
293
- if 0 <= cnt < len(all_args):
294
- defaults[all_args[cnt].arg] = self.decode_node(d)
295
-
296
- start = len(node.args.kwonlyargs) - len(node.args.kw_defaults)
297
- for cnt, d in enumerate(node.args.kw_defaults, start=start):
298
- if not d:
299
- continue
300
- self.visit(d)
301
- if 0 <= cnt < len(node.args.kwonlyargs):
302
- defaults[node.args.kwonlyargs[cnt].arg] = self.decode_node(d)
303
- return defaults
304
-
305
- PreProcessor._get_fun_defaults = _patched_get_fun_defaults # type: ignore[method-assign]
306
- PreProcessor._posonly_patched = True # type: ignore[attr-defined]
307
- logger.debug("PyCG: applied positional-only-param default patch (Python 3.8+ fix)")
308
- except Exception:
309
- pass
310
-
311
-
312
- def _import_pycg() -> Any:
313
- """Import PyCG's CallGraphGenerator, trying both 'pycg' and 'PyCG' package names.
314
-
315
- The PyPI distribution installs as ``PyCG/`` (mixed case). Python's importer
316
- is case-sensitive even on macOS HFS+, so we try both names and normalise
317
- ``pycg`` in sys.modules so PyCG's own ``from pycg import utils`` resolves
318
- regardless of which name the finder used first.
319
-
320
- Returns the ``CallGraphGenerator`` class.
321
- Raises ``PyCGExceptions.PyCGImportError`` if neither name is importable.
322
- """
323
- import importlib
324
- import sys
325
-
326
- for pkg_name in ("pycg", "PyCG"):
327
- try:
328
- mod = importlib.import_module(pkg_name)
329
- sys.modules.setdefault("pycg", mod)
330
- sys.modules.setdefault("PyCG", mod)
331
- pycg_mod = importlib.import_module(f"{pkg_name}.pycg")
332
- return pycg_mod.CallGraphGenerator
333
- except ImportError:
334
- continue
335
-
336
- raise PyCGExceptions.PyCGImportError(
337
- "pycg is not installed — run `pip install pycg`"
338
- )
339
-
340
-
341
- class _PyCGCallableResolver:
342
- """Maps a PyCG dotted namespace string to a ``PyCallable.signature``.
343
-
344
- PyCG names callables as ``module.Class.method`` relative to the package
345
- root, which is identical to our ``PyCallable.signature`` format. A
346
- direct dict lookup is therefore sufficient; this class exists to hold
347
- the index and make the ghost-node fallback explicit.
348
- """
349
-
350
- def __init__(self, known: Set[str]) -> None:
351
- self._known = known
352
-
353
- @classmethod
354
- def from_symbol_table(
355
- cls, symbol_table: Dict[str, PyModule]
356
- ) -> "_PyCGCallableResolver":
357
- known = {c.signature for c in iter_callables_in_symbol_table(symbol_table)}
358
- return cls(known)
359
-
360
- def resolve(self, pycg_name: str) -> str:
361
- """Return the canonical signature for *pycg_name*.
362
-
363
- If the name is in the symbol table it is returned verbatim.
364
- Otherwise it is returned as-is so the edge is preserved as a
365
- ghost (external / library) node in the call graph.
366
- """
367
- return pycg_name
368
-
369
-
370
- class PyCG:
371
- """Thin wrapper around PyCG's ``CallGraphGenerator``.
372
-
373
- Args:
374
- project_dir: Root of the Python project to analyse.
375
- skip_tests: When ``True``, files whose path contains ``test`` or
376
- ``conftest`` are excluded from the entry-point list.
377
- shard: When ``True``, run PyCG independently per Python package
378
- root instead of over the whole project. Required for projects
379
- that exceed the 500-file ceiling.
380
- shard_ceiling: Maximum file count per shard. Shards exceeding this
381
- limit are skipped. Defaults to ``_PYCG_SHARD_CEILING`` (100).
382
- shard_timeout: Per-shard wall-clock timeout in seconds. A shard that
383
- exceeds this limit is skipped. 0 disables the timeout. Defaults
384
- to ``_PYCG_SHARD_TIMEOUT`` (120). POSIX only; no-op on Windows.
385
- """
386
-
387
- # PyCG's pointer analysis is practical only up to this many files.
388
- # Its per-iteration cost grows super-linearly; on very large projects
389
- # even a single pass can take tens of minutes.
390
- _PYCG_FILE_CEILING: int = 500
391
-
392
- # Separate, tighter ceiling applied per shard in sharding mode.
393
- # A shard covers one Python package root; PyCG follows imports only
394
- # within that boundary. Even so, packages with deep class hierarchies
395
- # or heavily interconnected imports can cause PyCG's pointer fixpoint
396
- # to diverge well before the whole-project ceiling. 100 files is the
397
- # conservative default; override via --pycg-shard-ceiling.
398
- _PYCG_SHARD_CEILING: int = 100
399
-
400
- # Per-shard wall-clock timeout (seconds). PyCG's fixpoint is bimodal:
401
- # either it converges in seconds or it diverges and never finishes.
402
- # This timeout acts as a final safety net after the file-count ceiling.
403
- # 120 seconds is generous enough for any legitimately complex shard
404
- # while still catching non-converging ones. Override via
405
- # --pycg-shard-timeout. Set to 0 to disable.
406
- _PYCG_SHARD_TIMEOUT: int = 120
407
-
408
- # Cap on PyCG's outer fixpoint passes. PyCG runs PostProcessor until the
409
- # def/scope/MRO state stops changing; its abstract domain (field-sensitive
410
- # access paths, no k-limiting or widening) has no ascending-chain bound, so
411
- # on heavy metaclass/mixin code (e.g. an ORM) the def set can balloon into
412
- # the thousands and each O(defs^2) pass costs seconds — convergence, if it
413
- # comes, takes many passes. A finite cap turns "loop until killed" into a
414
- # sound-but-incomplete result that still returns the edges found so far.
415
- # 50 is generous — well-behaved code converges in well under 20 passes —
416
- # while bounding the pathological case. Override via --pycg-max-iter;
417
- # -1 restores PyCG's unbounded run-to-convergence behaviour.
418
- _PYCG_MAX_ITER: int = 50
419
-
420
- # Iterative decomposition of runaway (timed-out) shards: a shard that the
421
- # wall-clock timeout kills is re-partitioned at half the budget and re-run,
422
- # down to this file-count floor. Below the floor — or for an atomic import
423
- # cycle that won't split — the residue falls back to Jedi-only coverage.
424
- _PYCG_DECOMP_FLOOR: int = 10
425
- _PYCG_MAX_DECOMP_ROUNDS: int = 6
426
-
427
- # Directory names that should never be fed to PyCG as entry points, nor
428
- # followed into during import resolution (an in-tree .codeanalyzer venv /
429
- # site-packages lives under project_dir and would otherwise be pulled into
430
- # the package bound and analysed — see _shard_symlink_root).
431
- _SKIP_DIRS: frozenset = frozenset({
432
- ".codeanalyzer", ".git", "__pycache__",
433
- "venv", ".venv", "virtualenv", "env", ".env",
434
- "node_modules", "dist", "build", ".tox", ".nox",
435
- "site-packages",
436
- })
437
-
438
- def __init__(
439
- self,
440
- project_dir: Union[str, Path],
441
- skip_tests: bool = True,
442
- shard: bool = False,
443
- shard_ceiling: Optional[int] = None,
444
- shard_timeout: Optional[int] = None,
445
- shard_strategy: str = "jedi",
446
- max_iter: Optional[int] = None,
447
- using_ray: bool = False,
448
- ) -> None:
449
- self.project_dir = Path(project_dir).resolve()
450
- self.skip_tests = skip_tests
451
- self.shard = shard
452
- self.shard_ceiling = (
453
- shard_ceiling if shard_ceiling is not None else self._PYCG_SHARD_CEILING
454
- )
455
- self.shard_timeout = (
456
- shard_timeout if shard_timeout is not None else self._PYCG_SHARD_TIMEOUT
457
- )
458
- self.max_iter = max_iter if max_iter is not None else self._PYCG_MAX_ITER
459
- # "jedi": partition the Jedi module graph (SCC + Louvain) so coupled
460
- # modules co-compute and few edges are severed (see shard_planner).
461
- # "package": legacy one-shard-per-package-directory grouping.
462
- self.shard_strategy = shard_strategy
463
- self.using_ray = using_ray
464
- self._CallGraphGenerator: Optional[Any] = None
465
- self._resolver: Optional["_PyCGCallableResolver"] = None
466
-
467
- @staticmethod
468
- def _coalesce_edges(edges: List[PyCallEdge]) -> List[PyCallEdge]:
469
- """Sum weights of duplicate ``(source, target)`` pairs across shards."""
470
- merged: Dict[tuple, PyCallEdge] = {}
471
- for edge in edges:
472
- key = (edge.src, edge.dst)
473
- if key in merged:
474
- existing = merged[key]
475
- merged[key] = PyCallEdge(
476
- source=existing.source,
477
- target=existing.target,
478
- weight=existing.weight + edge.weight,
479
- prov=existing.prov,
480
- )
481
- else:
482
- merged[key] = edge
483
- return list(merged.values())
484
-
485
- # ------------------------------------------------------------------
486
- # Entry-point collection
487
- # ------------------------------------------------------------------
488
-
489
- def _collect_entry_points(self) -> List[str]:
490
- """Return absolute paths of project Python files, excluding caches and venvs."""
491
- paths = []
492
- for p in self.project_dir.rglob("*.py"):
493
- # Skip any file whose path passes through a filtered directory.
494
- if any(part in self._SKIP_DIRS for part in p.parts):
495
- continue
496
- # Skip test files using exact path-component matching, consistent
497
- # with core.py's _build_symbol_table filter. Substring matching
498
- # (e.g. "/test" in full_path_str) incorrectly excludes files in
499
- # paths like "test/fixtures/..." that are source files, not tests.
500
- rel_parts = p.relative_to(self.project_dir).parts
501
- if self.skip_tests and (
502
- "test" in rel_parts
503
- or "tests" in rel_parts
504
- or p.stem.startswith("test_")
505
- or p.name.endswith("_test.py")
506
- or p.name == "conftest.py"
507
- ):
508
- continue
509
- paths.append(str(p))
510
- # Sorted for run-to-run stability: rglob yields filesystem order, and
511
- # PyCG's capped fixpoint is sensitive to entry-point order (issue #99).
512
- return sorted(paths)
513
-
514
- # ------------------------------------------------------------------
515
- # Package-root helpers for sharding
516
- # ------------------------------------------------------------------
517
-
518
- @staticmethod
519
- def _find_package_root(file_path: Path, project_dir: Path) -> Path:
520
- """Return the top-level Python package directory that owns *file_path*.
521
-
522
- Walks upward from the file's directory toward *project_dir*, returning
523
- the highest ancestor that still contains an ``__init__.py``. Files
524
- at the project root (no ``__init__.py`` in any parent) are placed in
525
- a shard rooted at *project_dir* itself.
526
-
527
- Examples::
528
-
529
- project/addons/account/models/res.py → project/addons/account/
530
- project/src/flask/app.py → project/src/flask/
531
- project/standalone_script.py → project/
532
- """
533
- package_root = file_path.parent
534
- current = file_path.parent
535
- while current != project_dir:
536
- if not (current / "__init__.py").exists():
537
- break
538
- package_root = current
539
- current = current.parent
540
- return package_root
541
-
542
- @staticmethod
543
- def _package_prefix(pkg_root: Path, project_dir: Path) -> str:
544
- """Dot-separated path from *project_dir* to *pkg_root*.
545
-
546
- This prefix is prepended to PyCG's package-relative edge names so
547
- they become project-relative and align with the symbol table::
548
-
549
- pkg_root = project/addons/account/ → "addons.account"
550
- pkg_root = project/src/flask/ → "src.flask"
551
- pkg_root = project/ → "" (no prefix needed)
552
- """
553
- rel = pkg_root.relative_to(project_dir)
554
- return ".".join(rel.parts)
555
-
556
- # ------------------------------------------------------------------
557
- # Core PyCG runner
558
- # ------------------------------------------------------------------
559
-
560
- def _ensure_pycg_loaded(self) -> None:
561
- """Import PyCG and apply compatibility patches (idempotent)."""
562
- if self._CallGraphGenerator is not None:
563
- return
564
- self._CallGraphGenerator = _import_pycg()
565
- # Python 3.8+ positional-only-param fix and Python 3.13 import-hook fix.
566
- _apply_pycg_posonly_patch()
567
-
568
- def _run_pycg_batch(
569
- self,
570
- entry_points: List[str],
571
- package_dir: Path,
572
- resolver: "_PyCGCallableResolver",
573
- prefix: str = "",
574
- ) -> List[PyCallEdge]:
575
- """Run PyCG on *entry_points* with *package_dir* as the package root.
576
-
577
- *prefix* is a dot-separated path prepended to every edge name emitted
578
- by PyCG so that shard-relative names become project-relative. Pass
579
- ``""`` when *package_dir* is the project root (names already match).
580
-
581
- Raises ``PyCGExceptions.PyCGAnalysisError`` on any PyCG failure.
582
- """
583
- assert self._CallGraphGenerator is not None
584
- try:
585
- cg = self._CallGraphGenerator(
586
- entry_points=entry_points,
587
- package=str(package_dir),
588
- max_iter=self.max_iter,
589
- operation="call-graph",
590
- )
591
- cg.analyze()
592
- except TimeoutError:
593
- raise # propagate directly so _build_sharded logs a clean timeout message
594
- except Exception as exc:
595
- raise PyCGExceptions.PyCGAnalysisError(
596
- f"PyCG analysis failed: {exc}"
597
- ) from exc
598
-
599
- edge_counts: Counter = Counter()
600
- for src, dst in cg.output_edges():
601
- if prefix:
602
- src = f"{prefix}.{src}"
603
- dst = f"{prefix}.{dst}"
604
- edge_counts[(resolver.resolve(src), resolver.resolve(dst))] += 1
605
-
606
- return [
607
- PyCallEdge(src=src, dst=dst, weight=count, prov=["pycg"])
608
- for (src, dst), count in edge_counts.items()
609
- ]
610
-
611
- # ------------------------------------------------------------------
612
- # Sharded analysis
613
- # ------------------------------------------------------------------
614
-
615
- def _build_sharded_planned(
616
- self,
617
- jedi_edges: List[PyCallEdge],
618
- symbol_table: Dict[str, PyModule],
619
- resolver: "_PyCGCallableResolver",
620
- ) -> List[PyCallEdge]:
621
- """Coupling-aware sharding with iterative decomposition of runaways.
622
-
623
- Shards are chosen to *minimise the call edges severed between shards*:
624
- :func:`shard_planner.plan_shards` condenses the Jedi call graph by
625
- strongly-connected component (so import cycles never split) and clusters
626
- it with Louvain so tightly-coupled modules land together. Each shard is
627
- run through PyCG via a symlinked mini-project that bounds analysis to its
628
- files.
629
-
630
- PyCG's fixpoint diverges on heavy metaclass/mixin clusters, and a uniform
631
- ceiling would force *every* shard small (severing many edges) just to tame
632
- the few that run away. Instead we start coarse (low cut, high recall on
633
- healthy code) and **only re-decompose the shards that time out**: each
634
- runaway's files are re-partitioned at half the budget and re-run, down to
635
- a floor. A runaway shard contributes zero edges, so splitting it recovers
636
- almost all of them while paying cut on its internal seams alone. The
637
- residue that still diverges at the floor (or is an atomic cycle that won't
638
- split) falls back to Jedi-only coverage.
639
- """
640
- self._resolver = resolver
641
- plan = plan_shards(
642
- symbol_table, jedi_edges, budget=self.shard_ceiling, merge_small=True
643
- )
644
- m = plan.metrics
645
- logger.info(
646
- "PyCG: planned %d shard(s) from Jedi module graph "
647
- "(cut_ratio=%.3f, max_shard=%d files, %d modules)",
648
- int(m["num_shards"]), m["cut_ratio"],
649
- int(m["max_shard_files"]), int(m["modules"]),
650
- )
651
-
652
- runner = (
653
- self._run_fileset_shards_ray if self.using_ray
654
- else self._run_fileset_shards_seq
655
- )
656
- all_edges: List[PyCallEdge] = []
657
- shards = plan.shards
658
- budget = self.shard_ceiling
659
- converged_total = 0
660
- irreducible_files = 0
661
- round_no = 0
662
-
663
- while shards:
664
- label = "decomposition round %d (budget %d, %d shard(s))" % (
665
- round_no, budget, len(shards),
666
- )
667
- logger.info("PyCG: %s", label)
668
- edges, runaways = runner(shards)
669
- all_edges.extend(edges)
670
- converged_total += len(shards) - len(runaways)
671
- if not runaways:
672
- break
673
-
674
- next_budget = max(self._PYCG_DECOMP_FLOOR, budget // 2)
675
- stop_decomposing = (
676
- round_no >= self._PYCG_MAX_DECOMP_ROUNDS or next_budget >= budget
677
- )
678
-
679
- next_shards: List[List[str]] = []
680
- for rf in runaways:
681
- # Re-partition this runaway's files alone, at a tighter budget.
682
- # An atomic cycle (or a lone file) that won't shrink is
683
- # irreducible — accept Jedi-only rather than loop forever.
684
- sub_st = {f: symbol_table[f] for f in rf if f in symbol_table}
685
- if stop_decomposing or len(rf) <= 1:
686
- irreducible_files += len(rf)
687
- continue
688
- sub_plan = plan_shards(sub_st, jedi_edges, budget=next_budget)
689
- if len(sub_plan.shards) <= 1:
690
- # did not actually split (one atomic SCC) — give up on it
691
- irreducible_files += len(rf)
692
- continue
693
- next_shards.extend(sub_plan.shards)
694
-
695
- if not next_shards:
696
- break
697
- logger.info(
698
- "PyCG: %d shard(s) ran away — decomposing into %d sub-shard(s) "
699
- "at budget %d", len(runaways), len(next_shards), next_budget,
700
- )
701
- shards, budget = next_shards, next_budget
702
- round_no += 1
703
-
704
- if irreducible_files:
705
- logger.warning(
706
- "PyCG: %d file(s) in irreducibly-divergent shards fall back to "
707
- "Jedi-only coverage", irreducible_files,
708
- )
709
-
710
- result = self._coalesce_edges(all_edges)
711
- logger.info(
712
- "PyCG: %d edges from %d converged shard(s) over %d round(s) "
713
- "(%d before dedup, Jedi-planned%s)",
714
- len(result), converged_total, round_no + 1, len(all_edges),
715
- ", Ray-parallel" if self.using_ray else "",
716
- )
717
- return result
718
-
719
- def _run_fileset_shards_seq(
720
- self, shards: List[List[str]],
721
- ) -> Tuple[List[PyCallEdge], List[List[str]]]:
722
- """Run each file-set shard sequentially; return ``(edges, runaways)``.
723
-
724
- A shard that times out or raises is returned in *runaways* (its file
725
- list) for the caller to re-decompose; it contributes no edges.
726
- """
727
- resolver = self._resolver
728
- edges_all: List[PyCallEdge] = []
729
- runaways: List[List[str]] = []
730
- with ProgressBar(len(shards), "Building call graph shards", item_label="shards") as progress:
731
- for files in shards:
732
- try:
733
- with _shard_symlink_root(files, self.project_dir) as (root, eps):
734
- with _shard_timeout(self.shard_timeout):
735
- edges = self._run_pycg_batch(eps, root, resolver, prefix="")
736
- edges_all.extend(edges)
737
- except (TimeoutError, PyCGExceptions.PyCGAnalysisError):
738
- runaways.append(files)
739
- progress.advance()
740
- return edges_all, runaways
741
-
742
- def _run_fileset_shards_ray(
743
- self, shards: List[List[str]],
744
- ) -> Tuple[List[PyCallEdge], List[List[str]]]:
745
- """Ray-parallel variant of :meth:`_run_fileset_shards_seq`.
746
-
747
- Each shard is materialised as a symlink mini-project up front (the trees
748
- must outlive their remote tasks), submitted as a Ray task, and collected
749
- against one wall-clock deadline — Ray workers cannot use SIGALRM, so the
750
- timeout is enforced orchestrator-side. Timed-out/failed shards become
751
- runaways; symlink trees are removed once the batch completes.
752
- """
753
- import os
754
- import ray
755
- from codeanalyzer.core import _ensure_ray
756
- _ensure_ray()
757
-
758
- os.environ.setdefault("RAY_IGNORE_UNHANDLED_ERRORS", "1")
759
- remote_fn = ray.remote(_pycg_shard_worker)
760
-
761
- roots: List[Path] = []
762
- lock_fds: List[int] = []
763
- futures: List[Any] = []
764
- meta: Dict[Any, List[str]] = {} # ObjectRef -> shard file list
765
- edges_all: List[PyCallEdge] = []
766
- runaways: List[List[str]] = []
767
- try:
768
- with ProgressBar(len(shards), "Building call graph shards (parallel)", item_label="shards") as progress:
769
- for files in shards:
770
- # Deterministic roots can collide across concurrent
771
- # analyses of the same project — the driver holds each
772
- # shard's sidecar lock for the whole Ray fan-out (released
773
- # in the finally below with the root cleanup).
774
- lock_fd = os.open(
775
- str(_shard_root_path(files, self.project_dir).with_suffix(".lock")),
776
- os.O_CREAT | os.O_RDWR,
777
- )
778
- fcntl.flock(lock_fd, fcntl.LOCK_EX)
779
- lock_fds.append(lock_fd)
780
- root, eps = _materialize_shard_root(files, self.project_dir)
781
- roots.append(root)
782
- fut = remote_fn.remote(eps, str(root), "", self.max_iter)
783
- futures.append(fut)
784
- meta[fut] = files
785
-
786
- deadline = (
787
- time.perf_counter() + float(self.shard_timeout)
788
- if self.shard_timeout > 0 else None
789
- )
790
- pending = list(futures)
791
- while pending:
792
- if deadline is not None:
793
- remaining = deadline - time.perf_counter()
794
- if remaining <= 0:
795
- break
796
- else:
797
- remaining = None
798
-
799
- ready, pending = ray.wait(pending, num_returns=1, timeout=remaining)
800
- if not ready:
801
- break
802
-
803
- fut = ready[0]
804
- try:
805
- triples = ray.get(fut)
806
- edges_all.extend(
807
- PyCallEdge(src=s, dst=t, weight=w, prov=["pycg"])
808
- for s, t, w in triples
809
- )
810
- except Exception:
811
- runaways.append(meta[fut])
812
- progress.advance()
813
-
814
- for fut in pending: # exceeded the deadline
815
- ray.cancel(fut, force=True)
816
- runaways.append(meta[fut])
817
- progress.advance()
818
- finally:
819
- for root in roots:
820
- shutil.rmtree(root, ignore_errors=True)
821
- for fd in lock_fds:
822
- try:
823
- fcntl.flock(fd, fcntl.LOCK_UN)
824
- os.close(fd)
825
- except OSError:
826
- pass
827
- return edges_all, runaways
828
-
829
- def _build_sharded(
830
- self,
831
- entry_points: List[str],
832
- resolver: "_PyCGCallableResolver",
833
- ) -> List[PyCallEdge]:
834
- """Run PyCG per Python package shard and merge the results.
835
-
836
- Groups entry points by their top-level package root. Each shard
837
- whose size is within ``self.shard_ceiling`` is analysed independently
838
- with its package directory as the PyCG ``package`` root, which limits
839
- recursive import-following to that package boundary. Shards that
840
- exceed the shard ceiling are skipped with a warning (framework modules
841
- with deep mixin hierarchies can cause PyCG's fixpoint to diverge).
842
-
843
- Edge names are normalised to project-relative dotted paths so they
844
- match the symbol table's ``PyCallable.signature`` namespace.
845
- """
846
- shards: Dict[Path, List[str]] = defaultdict(list)
847
- for ep in entry_points:
848
- pkg_root = self._find_package_root(Path(ep), self.project_dir)
849
- shards[pkg_root].append(ep)
850
-
851
- logger.debug(
852
- "PyCG: sharding %d files into %d package shard(s)",
853
- len(entry_points), len(shards),
854
- )
855
-
856
- if self.using_ray:
857
- return self._build_sharded_ray(shards)
858
-
859
- all_edges: List[PyCallEdge] = []
860
- skipped = 0
861
- with ProgressBar(len(shards), "Building call graph shards", item_label="shards") as progress:
862
- for pkg_root, files in shards.items():
863
- n = len(files)
864
- pkg_label = str(pkg_root.relative_to(self.project_dir)) or "."
865
- if n > self.shard_ceiling:
866
- logger.warning(
867
- "PyCG shard '%s': %d files exceeds shard ceiling of %d — skipped",
868
- pkg_label, n, self.shard_ceiling,
869
- )
870
- skipped += 1
871
- progress.advance()
872
- continue
873
- prefix = self._package_prefix(pkg_root, self.project_dir)
874
- try:
875
- with _shard_timeout(self.shard_timeout):
876
- edges = self._run_pycg_batch(files, pkg_root, resolver, prefix=prefix)
877
- all_edges.extend(edges)
878
- logger.debug(
879
- "PyCG shard '%s': %d edges from %d files",
880
- pkg_label, len(edges), n,
881
- )
882
- except TimeoutError:
883
- logger.warning(
884
- "PyCG shard '%s' timed out after %ds — skipped",
885
- pkg_label, self.shard_timeout,
886
- )
887
- skipped += 1
888
- except PyCGExceptions.PyCGAnalysisError as exc:
889
- logger.warning("PyCG shard '%s' failed — skipped: %s", pkg_label, exc)
890
- skipped += 1
891
- progress.advance()
892
-
893
- if skipped:
894
- logger.warning(
895
- "PyCG: %d shard(s) were skipped (exceeded %d-file ceiling, "
896
- "%ds timeout, or failed)",
897
- skipped, self.shard_ceiling, self.shard_timeout,
898
- )
899
-
900
- # Merge duplicate (source, target) pairs that appear in multiple shards.
901
- merged: Dict[tuple, PyCallEdge] = {}
902
- for edge in all_edges:
903
- key = (edge.src, edge.dst)
904
- if key in merged:
905
- existing = merged[key]
906
- merged[key] = PyCallEdge(
907
- source=existing.source,
908
- target=existing.target,
909
- weight=existing.weight + edge.weight,
910
- prov=existing.prov,
911
- )
912
- else:
913
- merged[key] = edge
914
-
915
- result = list(merged.values())
916
- logger.info(
917
- "PyCG: %d edges from %d/%d shard(s) (%d before dedup)",
918
- len(result), len(shards) - skipped, len(shards), len(all_edges),
919
- )
920
- return result
921
-
922
- def _build_sharded_ray(self, shards: Dict[Path, List[str]]) -> List[PyCallEdge]:
923
- """Ray-parallel variant of the sequential shard loop.
924
-
925
- All eligible shards are submitted as Ray remote tasks simultaneously.
926
- ``ray.wait(timeout=shard_timeout)`` is used to collect results and
927
- cancel stragglers — Ray workers cannot use SIGALRM, so the timeout is
928
- enforced at the orchestrator level instead.
929
- """
930
- import os
931
- import ray
932
- from codeanalyzer.core import _ensure_ray
933
- _ensure_ray()
934
-
935
- # force-cancel kills worker processes; suppress Ray's "worker died
936
- # unexpectedly" noise since the death is intentional here.
937
- os.environ.setdefault("RAY_IGNORE_UNHANDLED_ERRORS", "1")
938
-
939
- remote_fn = ray.remote(_pycg_shard_worker)
940
- futures: List[Any] = []
941
- meta: Dict[Any, tuple] = {} # ObjectRef -> (pkg_label, n_files)
942
- skipped = 0
943
-
944
- all_edges: List[PyCallEdge] = []
945
- with ProgressBar(len(shards), "Building call graph shards (parallel)", item_label="shards") as progress:
946
- for pkg_root, files in shards.items():
947
- n = len(files)
948
- pkg_label = str(pkg_root.relative_to(self.project_dir)) or "."
949
- if n > self.shard_ceiling:
950
- logger.warning(
951
- "PyCG shard '%s': %d files exceeds shard ceiling of %d — skipped",
952
- pkg_label, n, self.shard_ceiling,
953
- )
954
- skipped += 1
955
- progress.advance()
956
- continue
957
- prefix = self._package_prefix(pkg_root, self.project_dir)
958
- fut = remote_fn.remote(files, str(pkg_root), prefix, self.max_iter)
959
- futures.append(fut)
960
- meta[fut] = (pkg_label, n)
961
-
962
- # Collect results one shard at a time so the progress bar ticks per
963
- # completed shard. A single deadline governs the whole batch: tasks
964
- # submitted simultaneously all have the same wall-clock budget.
965
- deadline = (
966
- time.perf_counter() + float(self.shard_timeout)
967
- if self.shard_timeout > 0 else None
968
- )
969
- pending = list(futures)
970
- while pending:
971
- if deadline is not None:
972
- remaining = deadline - time.perf_counter()
973
- if remaining <= 0:
974
- break
975
- else:
976
- remaining = None
977
-
978
- ready, pending = ray.wait(pending, num_returns=1, timeout=remaining)
979
- if not ready:
980
- break # deadline reached before any new result
981
-
982
- fut = ready[0]
983
- pkg_label, n = meta[fut]
984
- try:
985
- triples = ray.get(fut)
986
- edges = [
987
- PyCallEdge(src=s, dst=t, weight=w, prov=["pycg"])
988
- for s, t, w in triples
989
- ]
990
- all_edges.extend(edges)
991
- logger.debug(
992
- "PyCG shard '%s': %d edges from %d files (Ray)",
993
- pkg_label, len(edges), n,
994
- )
995
- except Exception as exc:
996
- logger.warning("PyCG shard '%s' failed — skipped: %s", pkg_label, exc)
997
- skipped += 1
998
- progress.advance()
999
-
1000
- # Cancel any shards that did not complete before the deadline.
1001
- for fut in pending:
1002
- pkg_label, _ = meta[fut]
1003
- logger.warning(
1004
- "PyCG shard '%s' timed out after %ds — skipped",
1005
- pkg_label, self.shard_timeout,
1006
- )
1007
- ray.cancel(fut, force=True)
1008
- skipped += 1
1009
- progress.advance()
1010
-
1011
- if skipped:
1012
- logger.warning(
1013
- "PyCG: %d shard(s) were skipped (exceeded %d-file ceiling, "
1014
- "%ds timeout, or failed)",
1015
- skipped, self.shard_ceiling, self.shard_timeout,
1016
- )
1017
-
1018
- merged: Dict[tuple, PyCallEdge] = {}
1019
- for edge in all_edges:
1020
- key = (edge.src, edge.dst)
1021
- if key in merged:
1022
- existing = merged[key]
1023
- merged[key] = PyCallEdge(
1024
- source=existing.source,
1025
- target=existing.target,
1026
- weight=existing.weight + edge.weight,
1027
- prov=existing.prov,
1028
- )
1029
- else:
1030
- merged[key] = edge
1031
-
1032
- result = list(merged.values())
1033
- logger.info(
1034
- "PyCG: %d edges from %d/%d shard(s) (%d before dedup, Ray-parallel)",
1035
- len(result), len(shards) - skipped, len(shards), len(all_edges),
1036
- )
1037
- return result
1038
-
1039
- # ------------------------------------------------------------------
1040
- # Public API
1041
- # ------------------------------------------------------------------
1042
-
1043
- def build_call_graph_edges(
1044
- self,
1045
- symbol_table: Dict[str, PyModule],
1046
- jedi_edges: Optional[List[PyCallEdge]] = None,
1047
- ) -> List[PyCallEdge]:
1048
- """Run PyCG and return ``PyCallEdge`` entries with ``prov=["pycg"]``.
1049
-
1050
- Edges are coalesced on ``(source, target)`` — ``weight`` equals the
1051
- number of times PyCG reports the same (caller, callee) pair (always 1
1052
- per unique pair in PyCG's output). Ghost callees (not in the symbol
1053
- table) are preserved so external / library edges appear in the graph.
1054
-
1055
- Returns an empty list and logs a warning if pycg is not installed or
1056
- if the analysis raises an unexpected exception.
1057
-
1058
- When ``self.shard=True`` and the project exceeds the 500-file ceiling,
1059
- PyCG is run per Python package root (see :meth:`_build_sharded`).
1060
- When ``self.shard=False`` and the project exceeds the ceiling, PyCG is
1061
- skipped and an empty list is returned (Jedi-only fallback).
1062
- """
1063
- try:
1064
- self._ensure_pycg_loaded()
1065
- except PyCGExceptions.PyCGImportError:
1066
- raise
1067
-
1068
- entry_points = self._collect_entry_points()
1069
- if not entry_points:
1070
- logger.debug("PyCG: no Python files found under %s", self.project_dir)
1071
- return []
1072
-
1073
- n_files = len(entry_points)
1074
- resolver = _PyCGCallableResolver.from_symbol_table(symbol_table)
1075
- t0 = time.perf_counter()
1076
-
1077
- if n_files > self._PYCG_FILE_CEILING:
1078
- if self.shard:
1079
- if self.shard_strategy == "jedi" and jedi_edges is not None:
1080
- logger.info(
1081
- "PyCG: starting Jedi-planned sharded analysis (%d files)",
1082
- n_files,
1083
- )
1084
- edges = self._build_sharded_planned(
1085
- jedi_edges, symbol_table, resolver
1086
- )
1087
- else:
1088
- mode = "Ray-parallel" if self.using_ray else "sequential"
1089
- logger.info(
1090
- "PyCG: starting per-package sharded analysis (%d files, %s)",
1091
- n_files, mode,
1092
- )
1093
- edges = self._build_sharded(entry_points, resolver)
1094
- else:
1095
- logger.warning(
1096
- "PyCG: %d entry points exceeds ceiling of %d — "
1097
- "skipping pointer analysis (Jedi-only edges will be used). "
1098
- "Re-run with --pycg-shard to analyse per package shard.",
1099
- n_files, self._PYCG_FILE_CEILING,
1100
- )
1101
- return []
1102
- else:
1103
- # Small project (≤ ceiling): whole-project analysis. Run inside a
1104
- # symlink mini-project mirroring only the (already SKIP_DIRS-filtered)
1105
- # entry points, so PyCG's package bound covers project source alone.
1106
- # Pointing PyCG at project_dir directly would put an in-tree
1107
- # .codeanalyzer venv / site-packages *under* mod_dir, and PyCG would
1108
- # follow imports into those dependencies and explode the analysis.
1109
- logger.info("PyCG: starting whole-project call graph analysis (%d files)", n_files)
1110
- with _shard_symlink_root(entry_points, self.project_dir) as (root, eps):
1111
- edges = self._run_pycg_batch(eps, root, resolver, prefix="")
1112
-
1113
- elapsed = time.perf_counter() - t0
1114
- logger.info("✅ PyCG: %d edges in %.1fs", len(edges), elapsed)
1115
- return edges