Skip to content

fix(cli): pin PYTHONHASHSEED for update/extract/cluster-only/label - #3743

Closed
ayushcodes10 wants to merge 3 commits into
Graphify-Labs:v8from
ayushcodes10:fix-3641-pin-hashseed-for-update-extract-cluster
Closed

ayushcodes10 wants to merge 3 commits into
Graphify-Labs:v8from
ayushcodes10:fix-3641-pin-hashseed-for-update-extract-cluster

Conversation

@ayushcodes10

Copy link
Copy Markdown
Contributor

Fixes #3641.

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, re-clustering differently every time and producing a large, spurious graphify-out diff (24% of node-community assignments flipped in one real repo per the issue's own measurement, also triggering the GitHub diff timeout from #3413).

PYTHONHASHSEED is read once at interpreter startup — I verified empirically that setting it on os.environ from inside an already-running process has zero effect on that process's own hash randomization (confirmed by hashing the same string twice, with and without a mid-process os.environ write, in separate runs — the hash still differs run to run). The only correct fix is to restart the interpreter with it set from the start, so main() now re-execs itself via os.execvpe before doing anything else, scoped to exactly update/extract/cluster-only/label (the commands whose output depends on clustering), and only when the caller hasn't already chosen a seed themselves. Degrades instead of raising if the re-exec itself fails.

A real bug I hit and fixed along the way: my first version had no pytest guard, and broke ~34+ existing tests across multiple files that call graphify.__main__.main() directly with a monkeypatched sys.argv to simulate a full CLI run in-process (e.g. test_extract_cli.py). Since main() now called a real os.execvpe as its first action, every such test call replaced the pytest worker process itself — silently, with zero output and no traceback, just an empty green-looking run. Fixed by skipping the pin while PYTEST_CURRENT_TEST (the env var pytest itself sets for the duration of a running test) is present.

Verified end-to-end via real subprocess invocation (python -m graphify update . with PYTHONHASHSEED unset) that the fix works and the re-exec chain correctly avoids an infinite loop — the second pass, now with the seed already set, sees the guard and proceeds normally.

Tests in tests/test_pin_hash_seed.py: 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 re-setting of PYTEST_CURRENT_TEST for the "call" phase — after fixtures resolve — would defeat anyway). Covers each affected command re-executing when unset, an explicit seed never being overridden, unrelated commands never re-executing, the pytest guard itself, graceful degradation on a failed re-exec, and a full real update invocation completing successfully end-to-end. Full suite (5721 passed, including the previously-broken CLI test files) is green.

🤖 Generated with Claude Code

https://claude.ai/code/session_017qfdzgbA5KedGEjD1AayNh

ayushcodes10 and others added 3 commits September 22, 2026 15:26
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017qfdzgbA5KedGEjD1AayNh
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017qfdzgbA5KedGEjD1AayNh
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017qfdzgbA5KedGEjD1AayNh

@graphify-labs graphify-labs Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Graphify reviewed this change.

Worth a look — the grounded gate found no coupling regressions or blocking issues, but 2 advisory finding(s) below merit a look before merge.


Graphify review — findings

Pins PYTHONHASHSEED=0 for graphify update/extract/cluster-only/label by re-execing the interpreter via _pin_hash_seed_if_needed before the CLI runs, so Louvain community assignments stay stable run-to-run and a bare graphify update . no longer produces a spurious re-clustered diff on every invocation. Leaves an explicitly-set PYTHONHASHSEED alone, skips the re-exec entirely under pytest (PYTEST_CURRENT_TEST), and degrades silently rather than raising if os.execvpe fails so unusual hosts can still run graphify. Adds tests/test_pin_hash_seed.py, which drives the guard from real subprocesses to observe the re-exec and its pinned env.

Worth a look

  • main() can now replace an embedding process — graphify/__main__.py:537 · Escalate · medium
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
  • Hash-seed reexec runs Windows console launcher as Python source — graphify/__main__.py:527 · Escalate · medium
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
Analysis details — impact, health, verification

Impact & health

Graphify review

Impact — 420 functions depend on the 236 functions this change touches.

Health — this change adds coupling hotspots:

  • new: main() — 98 callers, 3 callees
  • new: dispatch_command() — 2 callers, 125 callees
  • new: test_poisoned_manifest_is_healed() — 0 callers, 6 callees

Verification — 420 functions in the blast radius were not formally verified this run (proofs are advisory here).

Gate & verification

graphify gate

PASS — objectively clean (no health regressions, tests not run — proofs not run this pass (advisory)). Grounded, not self-assessed.

Advisory (not blocking):

  • verification_scope: 420 function(s) in the blast radius were not formally verified this run

Test selection

Test selection

291 of 291 test file(s) selected (100%) via static blast radius.

Escalated to a full run for safety — the selection is not trustworthy on its own (see below). CI should run the whole suite.

  • tests/test_affected_cli.py — impact, full-run-safety
  • tests/test_affected_member_seed.py — full-run-safety
  • tests/test_agents_platform.py — impact, full-run-safety
  • tests/test_analyze.py — full-run-safety
  • tests/test_anthropic_custom_endpoint.py — full-run-safety
  • tests/test_antigravity_install.py — full-run-safety
  • tests/test_apm_fallback_version.py — full-run-safety
  • tests/test_architecture_doc.py — full-run-safety
  • tests/test_astro_extraction.py — full-run-safety
  • tests/test_astro_import_ids.py — full-run-safety
  • tests/test_atomic_canvas_export.py — full-run-safety
  • tests/test_atomic_version_stamp.py — full-run-safety
  • tests/test_atomic_writes.py — full-run-safety
  • tests/test_backend_env_isolation.py — full-run-safety
  • tests/test_backend_extras.py — full-run-safety
  • tests/test_benchmark.py — full-run-safety
  • tests/test_benchmark_raw_graph.py — full-run-safety
  • tests/test_build.py — full-run-safety
  • tests/test_build_merge_dedup_scope.py — full-run-safety
  • tests/test_build_merge_hyperedges_and_prune.py — full-run-safety
  • tests/test_build_merge_shrink_guard.py — full-run-safety
  • tests/test_builtin_global_type_refs.py — full-run-safety
  • tests/test_cache.py — full-run-safety
  • tests/test_callflow_html.py — full-run-safety
  • tests/test_cargo_introspect.py — full-run-safety
  • tests/test_carried_hyperedge_remap.py — full-run-safety
  • tests/test_case_sensitive_resolution.py — full-run-safety
  • tests/test_charmap_encoding.py — full-run-safety
  • tests/test_chunking.py — full-run-safety
  • tests/test_cjs_module_extension.py — full-run-safety
  • tests/test_claude_cli_backend.py — full-run-safety
  • tests/test_claude_md.py — full-run-safety
  • tests/test_cli_broken_pipe.py — full-run-safety
  • tests/test_cli_export.py — full-run-safety
  • tests/test_cli_help.py — full-run-safety
  • tests/test_cluster.py — full-run-safety
  • tests/test_codebuddy.py — impact, full-run-safety
  • tests/test_community_hub_labels.py — full-run-safety
  • tests/test_community_labels_skill.py — full-run-safety
  • tests/test_confidence.py — full-run-safety
  • tests/test_corrupt_graph_json.py — full-run-safety
  • tests/test_cpp_nested_and_cli.py — full-run-safety
  • tests/test_cpp_objc_cross_file_calls.py — full-run-safety
  • tests/test_cpp_preprocess.py — full-run-safety
  • tests/test_cross_extension_reexport_self_cycle.py — full-run-safety
  • tests/test_cross_language_call_resolution.py — full-run-safety
  • tests/test_cross_repo_external_call_guards.py — full-run-safety
  • tests/test_cross_repo_member_calls.py — full-run-safety
  • tests/test_cross_repo_shared_types.py — full-run-safety
  • tests/test_csharp_call_site_generic_args.py — full-run-safety
  • … and 241 more

non-code file(s) changed (CHANGELOG.md) → running the full suite for safety (a code graph can't see config/fixture/data deps)

changed code file(s) with no mapped test (CHANGELOG.md) — a coverage gap or a missing link — running the full suite rather than only the selected tests

Selection is safe under the controlled-regression assumption; always-run tests + a periodic full run are the backstops. Advisory — it never changes the check verdict.

· 1 grounded finding(s) anchored inline below; 2 more finding(s) on lines outside this diff (see the check run).

Comment thread graphify/__main__.py
pass


def main() -> None:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regression — main()

98 callers depend on it (afferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

@safishamsi

Copy link
Copy Markdown
Collaborator

Shipped in v0.9.66 (on PyPI). Cherry-picked with authorship preserved so it shows under your GitHub contributions. Thanks @ayushcodes10! (Nice — the re-exec approach is exactly right for pinning the hash seed.)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

graphify update should pin PYTHONHASHSEED like the hooks do

2 participants