From b70382ceda595ff87cb8c05067e9c23952e6e43e Mon Sep 17 00:00:00 2001 From: ayushcodes10 Date: Tue, 22 Sep 2026 15:26:22 +0530 Subject: [PATCH 1/3] Pin PYTHONHASHSEED for update, extract, cluster only and label The generated git hooks already export PYTHONHASHSEED=0 because networkx's Louvain implementation iterates string keyed sets whose order is randomized per process, so community assignments otherwise churn between runs with no code change. A bare graphify update, which the CLAUDE.md template tells agents to run after every code change, skipped this, clustering differently every time and producing a large, spurious diff in the output directory. PYTHONHASHSEED is read once at interpreter startup, so setting it on os.environ from inside an already running process has no effect on that process's own hash randomization. The only way to pin it for a command already in flight is to restart the interpreter with it set from the start, so main() now re execs itself with the seed set before doing anything else, for exactly the commands whose output depends on clustering, only when the caller has not already chosen a seed themselves, degrading instead of raising if the re exec itself fails so an unusual host that disallows it can still run graphify. Skips this entirely while running under pytest, detected via the PYTEST_CURRENT_TEST variable pytest itself sets for the duration of a running test. Dozens of existing tests call main() directly with a monkeypatched argv to simulate a full CLI run in process, which only worked because main() was previously free of this kind of side effect; without the guard, every one of those calls replaced the test process running them the moment PYTHONHASHSEED happened to be unset, which it normally is in a dev or CI environment, with no traceback or error, just an empty, silently green test run. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017qfdzgbA5KedGEjD1AayNh --- graphify/__main__.py | 45 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/graphify/__main__.py b/graphify/__main__.py index 4a68e7240f..d4f266ce4b 100644 --- a/graphify/__main__.py +++ b/graphify/__main__.py @@ -483,11 +483,56 @@ def _silence_broken_pipe() -> None: sys.exit(0) +_HASHSEED_PINNED_COMMANDS = frozenset({"update", "extract", "cluster-only", "label"}) + + +def _pin_hash_seed_if_needed() -> None: + """Re-exec with PYTHONHASHSEED=0 for commands whose output must be + deterministic run-to-run (#3641). + + PYTHONHASHSEED is read once at interpreter startup; setting it on + os.environ from inside an already-running process has no effect on that + process's own hash randomization, so pinning it requires restarting the + interpreter with it set from the start. The generated git hooks already + export it for exactly this reason: networkx's Louvain implementation + iterates string-keyed sets whose order is randomized per-process, so + community assignments otherwise churn between runs with no code change. + A bare `graphify update .` (which the CLAUDE.md template tells agents to + run after every code change) skipped this, re-clustering differently + every time and producing a large, spurious graphify-out diff. + + Only re-execs for the commands whose output actually depends on + clustering, and only when the caller has not already set + PYTHONHASHSEED themselves — an explicit choice is left alone. Degrades + instead of raising if the re-exec itself fails, since an unusual host + that disallows it should still be able to run graphify, just without + this determinism guarantee. + + Also does nothing under pytest: dozens of existing tests call main() + directly with a monkeypatched sys.argv to simulate a full CLI run in + process, an approach that only works because main() was previously + side-effect-free at the point it starts. A real os.execvpe there would + replace the test process running those tests, not something to launch + for real -- PYTEST_CURRENT_TEST is set by pytest for exactly the + duration of a running test's setup/call/teardown, so this only ever + skips the pin inside an actual test, never a real invocation. + """ + if len(sys.argv) < 2 or sys.argv[1] not in _HASHSEED_PINNED_COMMANDS: + return + if "PYTHONHASHSEED" in os.environ or "PYTEST_CURRENT_TEST" in os.environ: + return + try: + os.execvpe(sys.executable, [sys.executable, *sys.argv], {**os.environ, "PYTHONHASHSEED": "0"}) + except OSError: + pass + + def main() -> None: """Console entry point. Wraps the CLI so that when a downstream consumer closes stdout early, graphify treats it as success instead of crashing with an unhandled write-to-closed-pipe error and exit 255 — which made CI wrappers and agent harnesses read a successful query as a command failure (#1807).""" + _pin_hash_seed_if_needed() try: _run_cli() # Flush explicitly, inside the guard. Piped stdout is block-buffered, so a From 2f5f2d705ee60b048defbb98a799ea1f00138caa Mon Sep 17 00:00:00 2001 From: ayushcodes10 Date: Tue, 22 Sep 2026 15:26:30 +0530 Subject: [PATCH 2/3] Add regression tests for the hash seed pinning fix Since a real re exec replaces the process it runs in, every test that needs to observe whether it fired runs a small probe in its own subprocess with a deliberately constructed environment, rather than mocking inside the pytest process itself, which pytest's own handling of its current test marker would defeat anyway. Cover each of the four affected commands re executing with the seed pinned when unset, an explicit seed never being overridden, unrelated commands never re executing, the guard against firing while pytest itself is mid test, a failed re exec being survived rather than raised, and a full real invocation of update still completing successfully end to end through the whole re exec. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017qfdzgbA5KedGEjD1AayNh --- tests/test_pin_hash_seed.py | 126 ++++++++++++++++++++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100644 tests/test_pin_hash_seed.py diff --git a/tests/test_pin_hash_seed.py b/tests/test_pin_hash_seed.py new file mode 100644 index 0000000000..d20eeb9ac0 --- /dev/null +++ b/tests/test_pin_hash_seed.py @@ -0,0 +1,126 @@ +"""#3641: `graphify update`/`extract`/`cluster-only` must pin PYTHONHASHSEED +like the generated git hooks already do. + +PYTHONHASHSEED is read once at interpreter startup, so it cannot be fixed by +setting os.environ from inside an already-running process -- the only way to +pin it for a command already in flight is to restart the interpreter with it +set from the start. `_pin_hash_seed_if_needed` does this via os.execvpe, +which replaces the current process, so a real call can only ever be observed +from OUTSIDE that process. + +That is also exactly why the function must never fire while running under +pytest in the first place: dozens of existing tests across the suite call +`graphify.__main__.main()` directly with a monkeypatched sys.argv to +simulate a full CLI run in process, which only works because main() was +previously side-effect-free at the point it starts -- a real os.execvpe +there would replace the pytest worker process running those tests. Pytest +itself re-sets PYTEST_CURRENT_TEST for the "call" phase right before a +test's own body runs (after fixtures resolve), so it cannot be cleared from +inside a test to simulate "not really under pytest" either. + +Both properties push every test here that needs execvpe to actually be +observed (or the guard to be proven) into a genuine subprocess with a +deliberately constructed environment, rather than mocking in process. +""" +from __future__ import annotations + +import json +import os +import subprocess +import sys + +_PROBE = """ +import json, os, sys +calls = [] +os.execvpe = lambda *a: calls.append(a) +sys.argv = {argv!r} +import graphify.__main__ as mainmod +mainmod._pin_hash_seed_if_needed() +print(json.dumps({{"called": bool(calls), "argv": calls[0][1] if calls else None, + "env_hashseed": calls[0][2].get("PYTHONHASHSEED") if calls else None}})) +""" + + +def _run_probe(argv: list[str], extra_env: dict | None = None) -> dict: + env = {k: v for k, v in os.environ.items() if k not in ("PYTHONHASHSEED", "PYTEST_CURRENT_TEST")} + env.update(extra_env or {}) + result = subprocess.run( + [sys.executable, "-c", _PROBE.format(argv=argv)], + capture_output=True, text=True, env=env, + ) + assert result.returncode == 0, f"probe crashed: {result.stderr}" + return json.loads(result.stdout) + + +def test_reexecs_for_hash_sensitive_commands_when_unset(): + for cmd in ("update", "extract", "cluster-only", "label"): + outcome = _run_probe(["graphify", cmd, "."]) + assert outcome["called"], f"{cmd} must re-exec with PYTHONHASHSEED pinned" + assert outcome["argv"] == [sys.executable, "graphify", cmd, "."] + assert outcome["env_hashseed"] == "0" + + +def test_does_not_reexec_when_already_set(): + outcome = _run_probe(["graphify", "update", "."], extra_env={"PYTHONHASHSEED": "1"}) + assert not outcome["called"], "an explicit PYTHONHASHSEED must never be overridden" + + +def test_does_not_reexec_for_unrelated_commands(): + for cmd in ("query", "install", "path", "explain"): + outcome = _run_probe(["graphify", cmd, "x"]) + assert not outcome["called"], f"{cmd} does not depend on clustering, must not re-exec" + + +def test_does_not_reexec_with_no_subcommand(): + outcome = _run_probe(["graphify"]) + assert not outcome["called"] + + +def test_does_not_reexec_while_pytest_current_test_is_set(): + """The safety guard itself, exercised outside a real pytest process by + planting the exact env var pytest sets while a test is running -- a + call shaped just like the ones dozens of existing CLI tests make must + not fire a real os.execvpe.""" + outcome = _run_probe( + ["graphify", "update", "."], + extra_env={"PYTEST_CURRENT_TEST": "tests/test_extract_cli.py::some_test (call)"}, + ) + assert not outcome["called"], "must never re-exec while PYTEST_CURRENT_TEST is set" + + +def test_degrades_instead_of_raising_when_reexec_fails(): + probe = """ +import os, sys +def _raise(*a): + raise OSError("exec not permitted") +os.execvpe = _raise +sys.argv = ["graphify", "update", "."] +import graphify.__main__ as mainmod +mainmod._pin_hash_seed_if_needed() # must not raise +print("survived") +""" + env = {k: v for k, v in os.environ.items() if k not in ("PYTHONHASHSEED", "PYTEST_CURRENT_TEST")} + result = subprocess.run([sys.executable, "-c", probe], capture_output=True, text=True, env=env) + assert result.returncode == 0, result.stderr + assert "survived" in result.stdout + + +def test_update_still_runs_end_to_end_with_hashseed_unset(tmp_path): + """Full subprocess smoke test: PYTHONHASHSEED unset and PYTEST_CURRENT_TEST + stripped from the child's env (a real invocation, not a pytest-guarded + one, the shape an interactive shell or an agent's own process has), must + still let `graphify update .` complete successfully all the way through + the re-exec.""" + (tmp_path / "a.py").write_text("def f():\n return g()\n\ndef g():\n return 1\n") + + env = { + k: v for k, v in os.environ.items() + if k not in ("PYTHONHASHSEED", "PYTEST_CURRENT_TEST") + } + result = subprocess.run( + [sys.executable, "-m", "graphify", "update", "."], + cwd=tmp_path, capture_output=True, text=True, env=env, + ) + + assert result.returncode == 0, result.stderr + assert (tmp_path / "graphify-out" / "graph.json").exists() From 6b22ff64d20392f895421dd2aa9d54a5c0284822 Mon Sep 17 00:00:00 2001 From: ayushcodes10 Date: Tue, 22 Sep 2026 15:26:43 +0530 Subject: [PATCH 3/3] Add changelog entry for issue 3641 Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017qfdzgbA5KedGEjD1AayNh --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c3e8767dc..3282dd198d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ Full release notes with details on each version: [GitHub Releases](https://githu - Fix: the incremental rebuild no longer purges AST nodes it just reported as fail-closed "kept" — the eviction pass re-checks the kept set, so a moved-file/symlink layout can't deadlock the shrink guard into refusing every update (#3697, #3695, thanks @hopstreax). - Fix: `graph.html` no longer crashes vis-network with a stack overflow on large graphs — nodes are seeded on a spiral before physics runs so overlap-avoidance can't blow the layout recursion (#3699, thanks @sanjaiyan-dev). - Fix: node and edge tooltips now show special characters literally (C++ templates like `vector`, generics, `&`, quotes) instead of raw HTML entities, while the HTML sinks that need escaping keep it (#3686, #3664, thanks @hopstreax). +- Fix: `graphify update`/`extract`/`cluster-only`/`label` now pin `PYTHONHASHSEED=0`, matching what the generated git hooks already do — a bare `graphify update .` (the command the CLAUDE.md template tells agents to run after every code change) previously re-clustered with a different, per-process-random hash seed every time, producing a large, spurious diff with no actual code change (#3641, thanks @lkiii). - Docs: repository links now point at `Graphify-Labs/graphify` instead of the old account (including in generated wiki output), translated READMEs use the current logo, GitHub issue/PR templates were added, and the Enterprise link was corrected (#3692, #3694, #3693, thanks @Abdul535). ## 0.9.64 (2026-09-18)