3939# re-enters PyCG's hook before its import graph is ready. Pre-importing
4040# these modules at import time ensures they're already in sys.modules when
4141# PyCG's hook is active, preventing the re-entrant ImportManagerError.
42+ import fcntl
43+ import hashlib
4244import importlib .metadata # noqa: F401
4345import importlib .util # noqa: F401
4446import contextlib
47+ import os
4548import json # noqa: F401
4649import shutil
4750import signal
@@ -85,6 +88,15 @@ def _handler(signum: int, frame: object) -> None:
8588from codeanalyzer .utils import ProgressBar , logger
8689
8790
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+
88100def _materialize_shard_root (
89101 files : List [str ],
90102 project_dir : Path ,
@@ -103,10 +115,21 @@ def _materialize_shard_root(
103115
104116 The caller owns the returned *root* and must ``shutil.rmtree`` it.
105117 """
106- root = Path (tempfile .mkdtemp (prefix = "canpy_pycg_shard_" ))
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 )
107130 entry_points : List [str ] = []
108131 linked_inits : Set [Path ] = set ()
109- for f in files :
132+ for f in sorted ( files ) :
110133 src = Path (f ).resolve ()
111134 try :
112135 rel = src .relative_to (project_dir )
@@ -142,12 +165,27 @@ def _shard_symlink_root(
142165 """Context-manager wrapper around :func:`_materialize_shard_root`.
143166
144167 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.
145175 """
146- root , entry_points = _materialize_shard_root (files , project_dir )
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 )
147179 try :
148- yield root , entry_points
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 )
149186 finally :
150- shutil .rmtree (root , ignore_errors = True )
187+ fcntl .flock (lock_fd , fcntl .LOCK_UN )
188+ os .close (lock_fd )
151189
152190
153191def _pycg_shard_worker (
@@ -469,7 +507,9 @@ def _collect_entry_points(self) -> List[str]:
469507 ):
470508 continue
471509 paths .append (str (p ))
472- return paths
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 )
473513
474514 # ------------------------------------------------------------------
475515 # Package-root helpers for sharding
@@ -712,18 +752,31 @@ def _run_fileset_shards_ray(
712752 """
713753 import os
714754 import ray
755+ from codeanalyzer .core import _ensure_ray
756+ _ensure_ray ()
715757
716758 os .environ .setdefault ("RAY_IGNORE_UNHANDLED_ERRORS" , "1" )
717759 remote_fn = ray .remote (_pycg_shard_worker )
718760
719761 roots : List [Path ] = []
762+ lock_fds : List [int ] = []
720763 futures : List [Any ] = []
721764 meta : Dict [Any , List [str ]] = {} # ObjectRef -> shard file list
722765 edges_all : List [PyCallEdge ] = []
723766 runaways : List [List [str ]] = []
724767 try :
725768 with ProgressBar (len (shards ), "Building call graph shards (parallel)" , item_label = "shards" ) as progress :
726769 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 )
727780 root , eps = _materialize_shard_root (files , self .project_dir )
728781 roots .append (root )
729782 fut = remote_fn .remote (eps , str (root ), "" , self .max_iter )
@@ -765,6 +818,12 @@ def _run_fileset_shards_ray(
765818 finally :
766819 for root in roots :
767820 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
768827 return edges_all , runaways
769828
770829 def _build_sharded (
@@ -870,6 +929,8 @@ def _build_sharded_ray(self, shards: Dict[Path, List[str]]) -> List[PyCallEdge]:
870929 """
871930 import os
872931 import ray
932+ from codeanalyzer .core import _ensure_ray
933+ _ensure_ray ()
873934
874935 # force-cancel kills worker processes; suppress Ray's "worker died
875936 # unexpectedly" noise since the death is intentional here.
0 commit comments