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) 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 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()