Skip to content

Commit 6fea370

Browse files
authored
Merge pull request #101 from codellm-devkit/fix/issue-99-deterministic-output
fix(determinism): byte-identical analysis output for identical runs
2 parents 27a99d4 + ae2df74 commit 6fea370

6 files changed

Lines changed: 278 additions & 23 deletions

File tree

CHANGELOG.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Fixed
11+
- **Deterministic output** (#99): the same flags on the same input now emit byte-identical
12+
`analysis.json`. Four independent nondeterminism sources were closed:
13+
- the CLI re-execs once with `PYTHONHASHSEED=0` (export the variable to opt out or pin another
14+
seed) — PyCG's capped fixpoint iterates hash-ordered sets, so an unpinned per-interpreter
15+
seed shifted its frontier arbitrarily (observed 1,527 vs 4,712 edges on the same input);
16+
Ray workers get the pinned seed via the runtime env;
17+
- the PyCG mini-project root is content-derived instead of a random `mkdtemp` (PyCG state keys
18+
on absolute module paths), with an exclusive sidecar lock so concurrent analyses of the same
19+
project serialize instead of deleting each other's tree mid-run;
20+
- entry points are sorted (was filesystem order) and the emitted `call_graph` is canonically
21+
ordered by `(src, dst)`;
22+
- Jedi inference candidates are tie-broken deterministically (sorted, not set order) — a
23+
union-typed receiver (e.g. `IOBase | BufferedRandom | TextIOWrapper`) previously resolved to
24+
a different member per run.
25+
Regression gates: `test_l2_runs_are_byte_identical` plus the #87 audit gate below.
26+
- **L2 audit gate** (#87 acceptance): ≥95% of resolved non-constructor callsites must bind to the
27+
invoked attribute name, and no callsite may fall back to a class id when the class declares the
28+
exact method (`test_l2_audit_gate_callee_name_equality`). The underlying anchoring fix shipped
29+
in 1.0.0 via the v0.3.1 merge.
30+
1031
## [1.0.0] - 2026-07-14
1132

1233
### Added

codeanalyzer/__main__.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,40 @@
1+
import os
2+
import sys
13
from importlib.metadata import version as _pkg_version, PackageNotFoundError
24
from pathlib import Path
35
from typing import Optional, Annotated
46

57
import typer
68

9+
10+
def _pin_hash_seed() -> None:
11+
"""Re-exec once with ``PYTHONHASHSEED=0`` unless the caller pinned one.
12+
13+
PyCG's capped fixpoint (``--pycg-max-iter``) iterates hash-ordered sets
14+
keyed on module/access-path strings, so an unpinned per-interpreter hash
15+
seed makes the emitted L2+ call graph vary run to run (issue #99). The
16+
seed cannot be set after interpreter start, hence the exec. Export
17+
PYTHONHASHSEED (any value) to opt out or pin a different seed.
18+
19+
Only fires when this process really is the CLI (canpy / python -m
20+
codeanalyzer): in-process invocations — e.g. Typer's CliRunner in the
21+
test suite, or a host app calling the callback — must never have their
22+
own process exec'd out from under them."""
23+
if os.environ.get("PYTHONHASHSEED") is not None:
24+
return
25+
argv0 = os.path.basename(sys.argv[0]) if sys.argv else ""
26+
is_cli = argv0 in ("canpy", "codeanalyzer") or sys.argv[0].endswith(
27+
os.path.join("codeanalyzer", "__main__.py")
28+
)
29+
if not is_cli:
30+
return
31+
env = dict(os.environ, PYTHONHASHSEED="0")
32+
os.execvpe(
33+
sys.executable,
34+
[sys.executable, "-m", "codeanalyzer", *sys.argv[1:]],
35+
env,
36+
)
37+
738
from codeanalyzer.core import Codeanalyzer
839
from codeanalyzer.utils import _set_log_level, logger
940
from codeanalyzer.config import OutputFormat
@@ -264,6 +295,10 @@ def main(
264295
),
265296
] = 50,
266297
):
298+
# Determinism: pin the interpreter hash seed before any analysis (no-op
299+
# when PYTHONHASHSEED is already set; --version exits before this).
300+
_pin_hash_seed()
301+
267302
# Flag validation (strict: unrecognized values error out, never fall back).
268303
selected_graphs = [g.strip() for g in graphs.split(",") if g.strip()]
269304
from codeanalyzer.dataflow.builder import VALID_GRAPHS

codeanalyzer/core.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,22 @@
3737
from codeanalyzer.options import AnalysisOptions
3838
from codeanalyzer.provenance import analyzer_info, repository_info
3939

40+
def _ensure_ray() -> None:
41+
"""Initialize Ray with the driver's pinned hash seed in the workers.
42+
43+
An implicit auto-init would not carry PYTHONHASHSEED into worker
44+
interpreters, so PyCG shards (and Jedi inference) run there with random
45+
set-iteration order and the emitted edges vary run to run (issue #99)."""
46+
if not ray.is_initialized():
47+
ray.init(
48+
runtime_env={
49+
"env_vars": {
50+
"PYTHONHASHSEED": os.environ.get("PYTHONHASHSEED", "0")
51+
}
52+
},
53+
)
54+
55+
4056
@ray.remote
4157
def _process_file_with_ray(py_file: Union[Path, str], project_dir: Union[Path, str], virtualenv: Union[Path, str, None]) -> Dict[str, PyModule]:
4258
"""Processes files in the project directory using Ray for distributed processing.
@@ -438,6 +454,11 @@ def analyze(self) -> Analysis:
438454
call_graph = merge_edges(call_graph, pycg_edges)
439455

440456
call_graph = filter_external_edges(call_graph, symbol_table)
457+
# Canonical edge order: backend iteration order (PyCG dicts, Counter
458+
# insertion) is not a contract — sort so identical edge SETS always
459+
# serialize identically (issue #99 determinism gate), and so the
460+
# external-symbol homing below assigns ids in a stable order.
461+
call_graph.sort(key=lambda e: (e.src, e.dst))
441462

442463
# Recreate pyapplication
443464
app = (
@@ -716,6 +737,7 @@ def _build_symbol_table(self, cached_symbol_table: Optional[Dict[str, PyModule]]
716737

717738
# Process only new/changed files with Ray
718739
if files_to_process:
740+
_ensure_ray()
719741
futures = [_process_file_with_ray.remote(py_file, self.project_dir, str(self.virtualenv) if self.virtualenv else None) for py_file in files_to_process]
720742

721743
with ProgressBar(len(futures), "Building symbol table (parallel)") as progress:

codeanalyzer/semantic_analysis/pycg/pycg_analysis.py

Lines changed: 67 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -39,9 +39,12 @@
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
4244
import importlib.metadata # noqa: F401
4345
import importlib.util # noqa: F401
4446
import contextlib
47+
import os
4548
import json # noqa: F401
4649
import shutil
4750
import signal
@@ -85,6 +88,15 @@ def _handler(signum: int, frame: object) -> None:
8588
from 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+
88100
def _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

153191
def _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.

codeanalyzer/syntactic_analysis/symbol_table_builder.py

Lines changed: 36 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -57,13 +57,26 @@ def _fallback_signature(self, script_path: Union[Path, str], name: str) -> str:
5757
relative = Path(script_path).relative_to(self.project_dir)
5858
return ".".join(relative.with_suffix("").parts) + f".{name}"
5959

60+
@staticmethod
61+
def _first_definition(definitions):
62+
"""Deterministic pick from Jedi's inference candidates.
63+
64+
On a union-typed receiver Jedi may return several candidates whose
65+
ORDER varies run to run (issue #99 — e.g. ``o.seek`` on
66+
``_IOBase | BufferedRandom | TextIOWrapper``); taking whichever came
67+
first made the emitted call graph nondeterministic. Sort on the
68+
stable identity (full_name, then name) and take the smallest."""
69+
if not definitions:
70+
return None
71+
return min(definitions, key=lambda d: (d.full_name or "", d.name or ""))
72+
6073
@staticmethod
6174
def _infer_type(script: Script, line: int, column: int) -> str:
6275
"""Tries to infer the type at a given position using Jedi."""
6376
try:
64-
inference = script.infer(line=line, column=column)
65-
if inference:
66-
return inference[0].name # or .full_name
77+
d = SymbolTableBuilder._first_definition(script.infer(line=line, column=column))
78+
if d is not None:
79+
return d.name # or .full_name
6780
except Exception:
6881
pass
6982
return None
@@ -82,9 +95,9 @@ def _infer_qualified_name(script: Script, line: int, column: int) -> Optional[st
8295
Optional[str]: The fully qualified name if available, else None.
8396
"""
8497
try:
85-
definitions = script.infer(line=line, column=column)
86-
if definitions:
87-
return definitions[0].full_name
98+
d = SymbolTableBuilder._first_definition(script.infer(line=line, column=column))
99+
if d is not None:
100+
return d.full_name
88101
except Exception:
89102
pass
90103
return None
@@ -103,10 +116,9 @@ def _infer_callee(
103116
the call graph.
104117
"""
105118
try:
106-
definitions = script.infer(line=line, column=column)
107-
if not definitions:
119+
d = SymbolTableBuilder._first_definition(script.infer(line=line, column=column))
120+
if d is None:
108121
return None, False
109-
d = definitions[0]
110122
is_class = (d.type == "class")
111123
full = d.full_name
112124
if is_class and full:
@@ -144,11 +156,17 @@ def _infer_call_return_type(script: Script, line: int, column: int) -> Optional[
144156
as the callee's own name.
145157
"""
146158
try:
147-
definitions = script.infer(line=line, column=column)
148-
if definitions:
149-
results = definitions[0].execute()
150-
if results:
151-
return results[0].name
159+
d = SymbolTableBuilder._first_definition(script.infer(line=line, column=column))
160+
if d is not None:
161+
# Drop NoneType results before picking: Jedi flaps between
162+
# yielding {NoneType} and {} for the None arm of an Optional
163+
# return (issue #99), and when a real type is also in the
164+
# union it is the informative choice — a bare NoneType says
165+
# nothing a missing return_type doesn't.
166+
results = [r for r in d.execute() if r.name != "NoneType"]
167+
r = SymbolTableBuilder._first_definition(results)
168+
if r is not None:
169+
return r.name
152170
except Exception:
153171
pass
154172
return None
@@ -974,9 +992,10 @@ def _symbol_from_name_node(
974992

975993
if script:
976994
try:
977-
definitions = script.infer(line=lineno, column=col_offset)
978-
if definitions:
979-
d = definitions[0]
995+
d = SymbolTableBuilder._first_definition(
996+
script.infer(line=lineno, column=col_offset)
997+
)
998+
if d is not None:
980999
inferred_type = d.name
9811000
qname = d.full_name
9821001
if d.type == "function":

0 commit comments

Comments
 (0)