From b5c78e7ec4e77ae0207da3ef1f8671d61a42a86c Mon Sep 17 00:00:00 2001 From: ayushcodes10 Date: Thu, 17 Sep 2026 01:57:17 +0530 Subject: [PATCH 01/12] Refuse to spawn an extraction pool that would spawn its own A guard less caller (no if __name__ == "__main__": block) makes every Windows spawned worker re execute the top level module on import. If that module calls extract() again at module scope, the worker opens its own pool, whose own guard less children do the same, faster than any per future BrokenProcessPool exception can surface and stop it. Two checks now run before the pool is ever opened: refuse unconditionally when already inside a multiprocessing child (a legitimate call only ever happens in the main process), and on Windows, skip the pool when the caller's own __main__ module has no guard, rather than only catching the failure after the fact. Co-Authored-By: Claude Sonnet 5 --- graphify/extract.py | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/graphify/extract.py b/graphify/extract.py index d54b841d9..aa0d2b3b3 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -6369,6 +6369,27 @@ def _extract_single_file(args: tuple) -> tuple[int, dict]: return idx, result +def _caller_main_lacks_guard() -> bool: + """#1637: on Windows (spawn start method), a caller script with no + ``if __name__ == "__main__":`` guard makes every worker re-execute the + top-level module on import — including, if it calls ``extract()`` at + module scope, spawning its OWN pool. Each of those child pools spawns + more children the same way, faster than any per-future exception can + surface and stop it: a fork bomb, not a slow failure. Read the caller's + own source (best-effort; a read failure means "can't tell", not "missing") + so the pool is never opened in the first place, rather than caught after + the fact via BrokenProcessPool once the damage is already spawning. + """ + main_file = getattr(sys.modules.get("__main__"), "__file__", None) + if not main_file: + return False + try: + main_src = Path(main_file).read_text(encoding="utf-8", errors="ignore") + except OSError: + return False + return "__main__" not in main_src + + def _extract_parallel( uncached_work: list[tuple[int, Path]], per_file: list[dict | None], @@ -6385,6 +6406,25 @@ def _extract_parallel( BrokenProcessPool); the caller should fall back to sequential extraction. """ import concurrent.futures + import multiprocessing + + # #1637: a legitimate call to extract() only ever happens in the main + # process. If we are somehow already running inside a spawned worker + # (the guard-less-caller re-execution case above), opening ANOTHER pool + # here is exactly the recursive step that turns a single missing guard + # into an unbounded process explosion. Refuse unconditionally, before + # even a spawn-capable platform check, since this is never correct. + if multiprocessing.parent_process() is not None: + return False + + if sys.platform == "win32" and _caller_main_lacks_guard(): + print( + " warning: calling script lacks an `if __name__ == \"__main__\":` " + "guard; extracting sequentially to avoid runaway process spawning " + "(pass parallel=False to extract() to silence this check)", + file=sys.stderr, flush=True, + ) + return False if max_workers is None: # Honour GRAPHIFY_MAX_WORKERS env override; otherwise scale to the From f658d3e50bf3d5fa3face4999d2feb509c61493d Mon Sep 17 00:00:00 2001 From: ayushcodes10 Date: Thu, 17 Sep 2026 01:57:22 +0530 Subject: [PATCH 02/12] Add regression tests for the runaway pool spawn fix Covers both new guards in _extract_parallel: refusing to open a pool from inside a spawned worker, and pre emptively declining the pool on Windows for a caller whose main module lacks the guard, while confirming a properly guarded caller still takes the pool path. Co-Authored-By: Claude Sonnet 5 --- tests/test_extract.py | 116 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 116 insertions(+) diff --git a/tests/test_extract.py b/tests/test_extract.py index d7a263b8e..54a794771 100644 --- a/tests/test_extract.py +++ b/tests/test_extract.py @@ -2307,6 +2307,122 @@ def submit(self, *a, **kw): assert spawned["count"] == 1, "multi-worker runs must still use the pool" +def test_extract_parallel_declines_pool_inside_a_spawned_worker(tmp_path, monkeypatch): + """#1637: a guard-less Windows caller makes every spawned worker re-execute + the top-level module. If that module calls extract() again at module + scope, the worker would open its OWN pool, whose own guard-less children + do the same — unbounded process growth, not a single recoverable + failure. _extract_parallel must refuse to open a pool at all whenever it + is already running inside a multiprocessing child, regardless of + platform, since a legitimate call only ever happens in the main process. + """ + import concurrent.futures + import multiprocessing + from graphify import extract as extract_mod + + spawned = {"count": 0} + + def fake_pool(*a, **kw): + spawned["count"] += 1 + raise AssertionError("ProcessPoolExecutor must not be constructed inside a worker") + + monkeypatch.setattr(concurrent.futures, "ProcessPoolExecutor", fake_pool) + monkeypatch.setattr(multiprocessing, "parent_process", lambda: object()) + + uncached = [(i, FIXTURES / "sample.py") for i in range(25)] + per_file: list = [None] * len(uncached) + + ok = extract_mod._extract_parallel(uncached, per_file, tmp_path, None, len(uncached)) + assert ok is False, "must decline and hand the work back for sequential extraction" + assert spawned["count"] == 0, "no pool may be spawned from inside a worker process" + + +def test_extract_parallel_declines_pool_on_windows_when_caller_lacks_guard( + tmp_path, monkeypatch +): + """#1637: on Windows, pre-empt the pool entirely when the caller script has + no `if __name__ == "__main__":` guard, instead of discovering the failure + only after BrokenProcessPool -- by then the pool has already started + respawning dying workers faster than the exception can stop it. + """ + import concurrent.futures + import multiprocessing + from graphify import extract as extract_mod + + guardless = tmp_path / "runner.py" + guardless.write_text("from graphify.extract import extract\nextract([])\n", encoding="utf-8") + + class FakeMain: + __file__ = str(guardless) + + monkeypatch.setattr(sys, "platform", "win32") + monkeypatch.setitem(sys.modules, "__main__", FakeMain()) + monkeypatch.setattr(multiprocessing, "parent_process", lambda: None) + + spawned = {"count": 0} + + def fake_pool(*a, **kw): + spawned["count"] += 1 + raise AssertionError("ProcessPoolExecutor must not be constructed for a guard-less caller") + + monkeypatch.setattr(concurrent.futures, "ProcessPoolExecutor", fake_pool) + + uncached = [(i, FIXTURES / "sample.py") for i in range(25)] + per_file: list = [None] * len(uncached) + + ok = extract_mod._extract_parallel(uncached, per_file, tmp_path, None, len(uncached)) + assert ok is False, "must decline and hand the work back for sequential extraction" + assert spawned["count"] == 0, "no pool may be spawned for a guard-less Windows caller" + + +def test_extract_parallel_still_spawns_pool_on_windows_when_caller_has_guard( + tmp_path, monkeypatch +): + """Guard the #1637 fix: a caller that DOES have the guard must still take + the pool path on Windows, so legitimate scripts keep their parallelism.""" + import concurrent.futures + import multiprocessing + from graphify import extract as extract_mod + + guarded = tmp_path / "runner.py" + guarded.write_text( + "from graphify.extract import extract\n" + "def main():\n" + " extract([])\n" + 'if __name__ == "__main__":\n' + " main()\n", + encoding="utf-8", + ) + + class FakeMain: + __file__ = str(guarded) + + monkeypatch.setattr(sys, "platform", "win32") + monkeypatch.setitem(sys.modules, "__main__", FakeMain()) + monkeypatch.setattr(multiprocessing, "parent_process", lambda: None) + monkeypatch.setenv("GRAPHIFY_MAX_WORKERS", "4") + + spawned = {"count": 0} + + class FakePool: + def __init__(self, *a, **kw): + spawned["count"] += 1 + def __enter__(self): + return self + def __exit__(self, *a): + return False + def submit(self, *a, **kw): + raise concurrent.futures.process.BrokenProcessPool("stop here") + + monkeypatch.setattr(concurrent.futures, "ProcessPoolExecutor", FakePool) + + uncached = [(i, FIXTURES / "sample.py") for i in range(25)] + per_file: list = [None] * len(uncached) + + extract_mod._extract_parallel(uncached, per_file, tmp_path, None, len(uncached)) + assert spawned["count"] == 1, "a guarded caller must still use the pool on Windows" + + def test_extract_falls_back_when_worker_future_breaks_pool( tmp_path, monkeypatch, capsys ): From b53ab1c7bb141e942b7367239556bea5f4c2e21e Mon Sep 17 00:00:00 2001 From: ayushcodes10 Date: Thu, 17 Sep 2026 01:57:45 +0530 Subject: [PATCH 03/12] Add changelog entry for issue 1637 Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a0daf515..56c1d2a5a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ Full release notes with details on each version: [GitHub Releases](https://githu ## 0.9.63 (2026-09-16) +- Fix: `extract()`'s parallel path no longer opens a `ProcessPoolExecutor` that can spawn its own. On Windows, a caller script with no `if __name__ == "__main__":` guard made every worker re execute the top level module on import — if that module called `extract()` again at module scope, the worker opened its own pool, whose own guard less children did the same, faster than a per future `BrokenProcessPool` exception could surface and stop it, growing unbounded rather than failing over to sequential extraction. Two checks now run before the pool is opened: unconditionally refuse when already inside a multiprocessing child, and on Windows, decline pre emptively when the caller's own `__main__` module lacks the guard (#1637, thanks @ray8875). - Feature: Elixir `alias`/`import`/`require`/`use` targets now resolve onto the module's `defmodule` node across files, so the internal module dependency graph is no longer dropped as dangling. Only top-level modules are indexed (a nested `defmodule`, labeled with its bare inner name, cannot capture an unrelated `use ` from another file), and a same-file reference is left unresolved so it cannot clobber the structural `contains` edge (#3603, thanks @ayushcodes10). - Feature: a Rust `self.method()` call now resolves to a method defined on the same type in another file (the common split-`impl`-block layout), pooling methods across every `impl` of one type and refusing to link when two unrelated types share a bare name (#3602, thanks @ayushcodes10). - Feature: a Ruby member call `obj.foo` on a known-type receiver now resolves to a method `foo` inherited from a superclass, including across files, using the same conservative promotion as the implicit-self resolver — a single owning class, matching method kind, and one unambiguous ancestry chain, or it stays dangling (#3585, thanks @oleksii-tumanov). From a325bd5454bc87ec3a3accce3d3a2625e2b2bb3a Mon Sep 17 00:00:00 2001 From: ayushcodes10 Date: Thu, 17 Sep 2026 20:55:22 +0530 Subject: [PATCH 04/12] Detect the real main guard statement, not a bare substring A review on this PR flagged that the guard less caller check used plain substring containment: any mention of __main__ anywhere in the caller's source, including a comment, docstring, or unrelated string literal, made the check report a guard that was not actually there, defeating the fork bomb protection this function exists to provide. Now matches the actual if statement (either operand order), so only a real guard counts. Co-Authored-By: Claude Sonnet 5 --- graphify/extract.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/graphify/extract.py b/graphify/extract.py index aa0d2b3b3..334aff205 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -6369,6 +6369,13 @@ def _extract_single_file(args: tuple) -> tuple[int, dict]: return idx, result +_MAIN_GUARD_RE = re.compile( + r'^[ \t]*if\s+(?:__name__\s*==\s*[\'"]__main__[\'"]' + r'|[\'"]__main__[\'"]\s*==\s*__name__)\s*:', + re.MULTILINE, +) + + def _caller_main_lacks_guard() -> bool: """#1637: on Windows (spawn start method), a caller script with no ``if __name__ == "__main__":`` guard makes every worker re-execute the @@ -6379,6 +6386,10 @@ def _caller_main_lacks_guard() -> bool: own source (best-effort; a read failure means "can't tell", not "missing") so the pool is never opened in the first place, rather than caught after the fact via BrokenProcessPool once the damage is already spawning. + + Looks for the actual guard statement, not a bare substring match — a + docstring, comment, or unrelated string literal mentioning ``__main__`` + must not be read as a guard that isn't really there. """ main_file = getattr(sys.modules.get("__main__"), "__file__", None) if not main_file: @@ -6387,7 +6398,7 @@ def _caller_main_lacks_guard() -> bool: main_src = Path(main_file).read_text(encoding="utf-8", errors="ignore") except OSError: return False - return "__main__" not in main_src + return _MAIN_GUARD_RE.search(main_src) is None def _extract_parallel( From b86d873d9f0a5e51e8337f2592e15aced889060a Mon Sep 17 00:00:00 2001 From: ayushcodes10 Date: Thu, 17 Sep 2026 20:55:25 +0530 Subject: [PATCH 05/12] Add regression tests for the real main guard detection Covers the false positive a plain reviewer found (an unrelated mention of __main__ in a comment or docstring must not be read as a guard) and the reversed operand order, which is valid Python and must still be recognized as a real guard. Co-Authored-By: Claude Sonnet 5 --- tests/test_extract.py | 92 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 92 insertions(+) diff --git a/tests/test_extract.py b/tests/test_extract.py index 54a794771..307753f2b 100644 --- a/tests/test_extract.py +++ b/tests/test_extract.py @@ -2375,6 +2375,51 @@ def fake_pool(*a, **kw): assert spawned["count"] == 0, "no pool may be spawned for a guard-less Windows caller" +def test_extract_parallel_declines_pool_when_main_only_appears_in_a_comment( + tmp_path, monkeypatch +): + """A caller with no real guard, whose source merely mentions __main__ in + a comment or docstring, must still be treated as guard-less. A bare + substring check on the source text ("__main__" in main_src) would read + that unrelated mention as a guard that is not actually there, and open + a pool for a caller that has none -- exactly the fork bomb condition + this check exists to prevent.""" + import concurrent.futures + import multiprocessing + from graphify import extract as extract_mod + + guardless = tmp_path / "runner.py" + guardless.write_text( + '"""Runs as __main__ in CI; see __main__ in the deploy docs."""\n' + "# note: __main__ is not actually guarded here\n" + "from graphify.extract import extract\n" + "extract([])\n", + encoding="utf-8", + ) + + class FakeMain: + __file__ = str(guardless) + + monkeypatch.setattr(sys, "platform", "win32") + monkeypatch.setitem(sys.modules, "__main__", FakeMain()) + monkeypatch.setattr(multiprocessing, "parent_process", lambda: None) + + spawned = {"count": 0} + + def fake_pool(*a, **kw): + spawned["count"] += 1 + raise AssertionError("ProcessPoolExecutor must not be constructed for a guard-less caller") + + monkeypatch.setattr(concurrent.futures, "ProcessPoolExecutor", fake_pool) + + uncached = [(i, FIXTURES / "sample.py") for i in range(25)] + per_file: list = [None] * len(uncached) + + ok = extract_mod._extract_parallel(uncached, per_file, tmp_path, None, len(uncached)) + assert ok is False, "an unrelated __main__ mention must not be read as a real guard" + assert spawned["count"] == 0, "no pool may be spawned for a guard-less Windows caller" + + def test_extract_parallel_still_spawns_pool_on_windows_when_caller_has_guard( tmp_path, monkeypatch ): @@ -2423,6 +2468,53 @@ def submit(self, *a, **kw): assert spawned["count"] == 1, "a guarded caller must still use the pool on Windows" +def test_extract_parallel_spawns_pool_for_reversed_guard_order(tmp_path, monkeypatch): + """The guard detection must also accept the less common, still valid + `if "__main__" == __name__:` operand order, not just the conventional + `if __name__ == "__main__":` spelling.""" + import concurrent.futures + import multiprocessing + from graphify import extract as extract_mod + + guarded = tmp_path / "runner.py" + guarded.write_text( + "from graphify.extract import extract\n" + "def main():\n" + " extract([])\n" + 'if "__main__" == __name__:\n' + " main()\n", + encoding="utf-8", + ) + + class FakeMain: + __file__ = str(guarded) + + monkeypatch.setattr(sys, "platform", "win32") + monkeypatch.setitem(sys.modules, "__main__", FakeMain()) + monkeypatch.setattr(multiprocessing, "parent_process", lambda: None) + monkeypatch.setenv("GRAPHIFY_MAX_WORKERS", "4") + + spawned = {"count": 0} + + class FakePool: + def __init__(self, *a, **kw): + spawned["count"] += 1 + def __enter__(self): + return self + def __exit__(self, *a): + return False + def submit(self, *a, **kw): + raise concurrent.futures.process.BrokenProcessPool("stop here") + + monkeypatch.setattr(concurrent.futures, "ProcessPoolExecutor", FakePool) + + uncached = [(i, FIXTURES / "sample.py") for i in range(25)] + per_file: list = [None] * len(uncached) + + extract_mod._extract_parallel(uncached, per_file, tmp_path, None, len(uncached)) + assert spawned["count"] == 1, "the reversed operand order is still a real guard" + + def test_extract_falls_back_when_worker_future_breaks_pool( tmp_path, monkeypatch, capsys ): From ff6f0ec5e5abb885cf817c851da1341a7975f61e Mon Sep 17 00:00:00 2001 From: ayushcodes10 Date: Thu, 17 Sep 2026 20:55:27 +0530 Subject: [PATCH 06/12] Update changelog entry for issue 1637 Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 56c1d2a5a..d33af6f7a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ Full release notes with details on each version: [GitHub Releases](https://githu ## 0.9.63 (2026-09-16) -- Fix: `extract()`'s parallel path no longer opens a `ProcessPoolExecutor` that can spawn its own. On Windows, a caller script with no `if __name__ == "__main__":` guard made every worker re execute the top level module on import — if that module called `extract()` again at module scope, the worker opened its own pool, whose own guard less children did the same, faster than a per future `BrokenProcessPool` exception could surface and stop it, growing unbounded rather than failing over to sequential extraction. Two checks now run before the pool is opened: unconditionally refuse when already inside a multiprocessing child, and on Windows, decline pre emptively when the caller's own `__main__` module lacks the guard (#1637, thanks @ray8875). +- Fix: `extract()`'s parallel path no longer opens a `ProcessPoolExecutor` that can spawn its own. On Windows, a caller script with no `if __name__ == "__main__":` guard made every worker re execute the top level module on import — if that module called `extract()` again at module scope, the worker opened its own pool, whose own guard less children did the same, faster than a per future `BrokenProcessPool` exception could surface and stop it, growing unbounded rather than failing over to sequential extraction. Two checks now run before the pool is opened: unconditionally refuse when already inside a multiprocessing child, and on Windows, decline pre emptively when the caller's own `__main__` module lacks the guard. The guard check itself now looks for the actual guard statement instead of a bare substring match, so a docstring, comment, or unrelated string literal that merely mentions `__main__` is no longer read as a guard that is not really there (#1637, thanks @ray8875). - Feature: Elixir `alias`/`import`/`require`/`use` targets now resolve onto the module's `defmodule` node across files, so the internal module dependency graph is no longer dropped as dangling. Only top-level modules are indexed (a nested `defmodule`, labeled with its bare inner name, cannot capture an unrelated `use ` from another file), and a same-file reference is left unresolved so it cannot clobber the structural `contains` edge (#3603, thanks @ayushcodes10). - Feature: a Rust `self.method()` call now resolves to a method defined on the same type in another file (the common split-`impl`-block layout), pooling methods across every `impl` of one type and refusing to link when two unrelated types share a bare name (#3602, thanks @ayushcodes10). - Feature: a Ruby member call `obj.foo` on a known-type receiver now resolves to a method `foo` inherited from a superclass, including across files, using the same conservative promotion as the implicit-self resolver — a single owning class, matching method kind, and one unambiguous ancestry chain, or it stays dangling (#3585, thanks @oleksii-tumanov). From 897e27e63eee082165ed2b8943f3d7766b99592f Mon Sep 17 00:00:00 2001 From: ayushcodes10 Date: Thu, 17 Sep 2026 21:40:09 +0530 Subject: [PATCH 07/12] Detect the main guard by parsing, not a regex over the text A formal review round on this PR found three real gaps in the regex this replaces: a guard shaped line sitting inside a triple quoted string or a docstring example was still read as a real guard, the exact false positive class this whole check exists to close, just needing more specific bait text to trigger. And a valid but less common parenthesized comparison was wrongly rejected as no guard at all, a regression from the plain substring check this branch started from. Now parses the caller's source with ast and looks for a real if statement whose test compares __name__ to the string "__main__" in either order. The parser never sees string or comment contents as code at all, and parens are transparent to it, so both gaps close at once. A source that fails to parse is treated the same as an unreadable file, matching the existing best effort philosophy here. Co-Authored-By: Claude Sonnet 5 --- graphify/extract.py | 47 ++++++++++++++++++++++++++++++++++++--------- 1 file changed, 38 insertions(+), 9 deletions(-) diff --git a/graphify/extract.py b/graphify/extract.py index 334aff205..1e35a3cc5 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -1,6 +1,7 @@ """Deterministic structural extraction from source code using tree-sitter. Outputs nodes+edges dicts.""" from __future__ import annotations +import ast import hashlib import importlib import json @@ -6369,11 +6370,26 @@ def _extract_single_file(args: tuple) -> tuple[int, dict]: return idx, result -_MAIN_GUARD_RE = re.compile( - r'^[ \t]*if\s+(?:__name__\s*==\s*[\'"]__main__[\'"]' - r'|[\'"]__main__[\'"]\s*==\s*__name__)\s*:', - re.MULTILINE, -) +def _is_main_guard_test(test: ast.expr) -> bool: + """Whether an ``if`` statement's test is ``__name__ == "__main__"``, in + either operand order. Parens around the comparison are transparent to + the AST, and this never looks inside a string, comment, or docstring — + only a real comparison expression in executable code satisfies it.""" + if not isinstance(test, ast.Compare): + return False + if len(test.ops) != 1 or not isinstance(test.ops[0], ast.Eq): + return False + left, right = test.left, test.comparators[0] + + def _is_dunder_name(node: ast.expr) -> bool: + return isinstance(node, ast.Name) and node.id == "__name__" + + def _is_main_string(node: ast.expr) -> bool: + return isinstance(node, ast.Constant) and node.value == "__main__" + + return (_is_dunder_name(left) and _is_main_string(right)) or ( + _is_main_string(left) and _is_dunder_name(right) + ) def _caller_main_lacks_guard() -> bool: @@ -6387,9 +6403,13 @@ def _caller_main_lacks_guard() -> bool: so the pool is never opened in the first place, rather than caught after the fact via BrokenProcessPool once the damage is already spawning. - Looks for the actual guard statement, not a bare substring match — a - docstring, comment, or unrelated string literal mentioning ``__main__`` - must not be read as a guard that isn't really there. + Parses the source and looks for a real ``if`` statement with this test, + rather than a regex over the text — a regex line match still treats a + guard-shaped line sitting inside a triple-quoted string or a docstring + example as a real guard (it is not executable code), and still rejects + a valid but less common form like a parenthesized comparison. The AST + does not see string contents as code at all, and is indifferent to + formatting, so both gaps close at once. """ main_file = getattr(sys.modules.get("__main__"), "__file__", None) if not main_file: @@ -6398,7 +6418,16 @@ def _caller_main_lacks_guard() -> bool: main_src = Path(main_file).read_text(encoding="utf-8", errors="ignore") except OSError: return False - return _MAIN_GUARD_RE.search(main_src) is None + try: + tree = ast.parse(main_src) + except SyntaxError: + # Can't tell whether a guard is present -- treated the same as an + # unreadable file above, not escalated into "assume it's missing". + return False + return not any( + isinstance(node, ast.If) and _is_main_guard_test(node.test) + for node in ast.walk(tree) + ) def _extract_parallel( From 16cb9d855df58fb2b68e2cb3c1cff2db98836854 Mon Sep 17 00:00:00 2001 From: ayushcodes10 Date: Thu, 17 Sep 2026 21:40:12 +0530 Subject: [PATCH 08/12] Add regression tests for the parser based guard detection Covers the three review reported gaps directly: guard text inside a triple quoted string, guard text inside a docstring example, and a parenthesized comparison, plus an unparseable caller falling back to the existing best effort treatment instead of being escalated. Co-Authored-By: Claude Sonnet 5 --- tests/test_extract.py | 183 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 183 insertions(+) diff --git a/tests/test_extract.py b/tests/test_extract.py index 307753f2b..2829134f5 100644 --- a/tests/test_extract.py +++ b/tests/test_extract.py @@ -2515,6 +2515,189 @@ def submit(self, *a, **kw): assert spawned["count"] == 1, "the reversed operand order is still a real guard" +def test_extract_parallel_declines_pool_when_guard_text_is_inside_a_string(tmp_path, monkeypatch): + """Review finding on the regex based detector this replaces: a guard + shaped line sitting inside a triple-quoted string is not executable + code and must not be read as a real guard. The caller here has no + actual if statement at all.""" + import concurrent.futures + import multiprocessing + from graphify import extract as extract_mod + + guardless = tmp_path / "runner.py" + guardless.write_text( + '"""\n' + "Example usage:\n" + 'if __name__ == "__main__":\n' + " main()\n" + '"""\n' + "from graphify.extract import extract\n" + "extract([])\n", + encoding="utf-8", + ) + + class FakeMain: + __file__ = str(guardless) + + monkeypatch.setattr(sys, "platform", "win32") + monkeypatch.setitem(sys.modules, "__main__", FakeMain()) + monkeypatch.setattr(multiprocessing, "parent_process", lambda: None) + + spawned = {"count": 0} + + def fake_pool(*a, **kw): + spawned["count"] += 1 + raise AssertionError("ProcessPoolExecutor must not be constructed for a guard-less caller") + + monkeypatch.setattr(concurrent.futures, "ProcessPoolExecutor", fake_pool) + + uncached = [(i, FIXTURES / "sample.py") for i in range(25)] + per_file: list = [None] * len(uncached) + + ok = extract_mod._extract_parallel(uncached, per_file, tmp_path, None, len(uncached)) + assert ok is False, "guard text inside a string is not a real guard" + assert spawned["count"] == 0 + + +def test_extract_parallel_declines_pool_when_guard_text_is_in_a_docstring_example( + tmp_path, monkeypatch +): + """Same class of finding, the other shape reported: a guard shaped line + inside a function's own docstring, documenting how to call it, must + not be read as the module actually having a guard.""" + import concurrent.futures + import multiprocessing + from graphify import extract as extract_mod + + guardless = tmp_path / "runner.py" + guardless.write_text( + "from graphify.extract import extract\n" + "\n" + "def run_from_cli():\n" + ' """Entry point.\n' + "\n" + " Typical usage:\n" + ' if __name__ == "__main__":\n' + " run_from_cli()\n" + ' """\n' + " extract([])\n" + "\n" + "run_from_cli()\n", + encoding="utf-8", + ) + + class FakeMain: + __file__ = str(guardless) + + monkeypatch.setattr(sys, "platform", "win32") + monkeypatch.setitem(sys.modules, "__main__", FakeMain()) + monkeypatch.setattr(multiprocessing, "parent_process", lambda: None) + + spawned = {"count": 0} + + def fake_pool(*a, **kw): + spawned["count"] += 1 + raise AssertionError("ProcessPoolExecutor must not be constructed for a guard-less caller") + + monkeypatch.setattr(concurrent.futures, "ProcessPoolExecutor", fake_pool) + + uncached = [(i, FIXTURES / "sample.py") for i in range(25)] + per_file: list = [None] * len(uncached) + + ok = extract_mod._extract_parallel(uncached, per_file, tmp_path, None, len(uncached)) + assert ok is False, "guard text inside a docstring example is not a real guard" + assert spawned["count"] == 0 + + +def test_extract_parallel_spawns_pool_for_a_parenthesized_guard(tmp_path, monkeypatch): + """Review finding: a parenthesized comparison, `if (__name__ == + "__main__"):`, is valid Python and a real guard, but was rejected by + the regex based detector this replaces since it required `if` to be + followed immediately by the comparison with no parens in between.""" + import concurrent.futures + import multiprocessing + from graphify import extract as extract_mod + + guarded = tmp_path / "runner.py" + guarded.write_text( + "from graphify.extract import extract\n" + "def main():\n" + " extract([])\n" + 'if (__name__ == "__main__"):\n' + " main()\n", + encoding="utf-8", + ) + + class FakeMain: + __file__ = str(guarded) + + monkeypatch.setattr(sys, "platform", "win32") + monkeypatch.setitem(sys.modules, "__main__", FakeMain()) + monkeypatch.setattr(multiprocessing, "parent_process", lambda: None) + monkeypatch.setenv("GRAPHIFY_MAX_WORKERS", "4") + + spawned = {"count": 0} + + class FakePool: + def __init__(self, *a, **kw): + spawned["count"] += 1 + def __enter__(self): + return self + def __exit__(self, *a): + return False + def submit(self, *a, **kw): + raise concurrent.futures.process.BrokenProcessPool("stop here") + + monkeypatch.setattr(concurrent.futures, "ProcessPoolExecutor", FakePool) + + uncached = [(i, FIXTURES / "sample.py") for i in range(25)] + per_file: list = [None] * len(uncached) + + extract_mod._extract_parallel(uncached, per_file, tmp_path, None, len(uncached)) + assert spawned["count"] == 1, "a parenthesized comparison is still a real guard" + + +def test_extract_parallel_spawns_pool_when_caller_source_fails_to_parse(tmp_path, monkeypatch): + """An unparseable caller (a syntax error, or a non-Python source read as + text) means the guard's presence genuinely can't be determined -- this + is treated the same as an unreadable file, not escalated into "assume + it's missing", matching this function's existing best-effort philosophy.""" + import concurrent.futures + import multiprocessing + from graphify import extract as extract_mod + + broken = tmp_path / "runner.py" + broken.write_text("def broken(:\n", encoding="utf-8") + + class FakeMain: + __file__ = str(broken) + + monkeypatch.setattr(sys, "platform", "win32") + monkeypatch.setitem(sys.modules, "__main__", FakeMain()) + monkeypatch.setattr(multiprocessing, "parent_process", lambda: None) + monkeypatch.setenv("GRAPHIFY_MAX_WORKERS", "4") + + spawned = {"count": 0} + + class FakePool: + def __init__(self, *a, **kw): + spawned["count"] += 1 + def __enter__(self): + return self + def __exit__(self, *a): + return False + def submit(self, *a, **kw): + raise concurrent.futures.process.BrokenProcessPool("stop here") + + monkeypatch.setattr(concurrent.futures, "ProcessPoolExecutor", FakePool) + + uncached = [(i, FIXTURES / "sample.py") for i in range(25)] + per_file: list = [None] * len(uncached) + + extract_mod._extract_parallel(uncached, per_file, tmp_path, None, len(uncached)) + assert spawned["count"] == 1, "an unparseable caller must not be treated as guard-less" + + def test_extract_falls_back_when_worker_future_breaks_pool( tmp_path, monkeypatch, capsys ): From b92aee27cdca8ef72d6b5d50a1ad39586c3da791 Mon Sep 17 00:00:00 2001 From: ayushcodes10 Date: Thu, 17 Sep 2026 21:40:21 +0530 Subject: [PATCH 09/12] Update changelog entry for issue 1637 review finding Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d33af6f7a..feba50861 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ Full release notes with details on each version: [GitHub Releases](https://githu ## 0.9.63 (2026-09-16) -- Fix: `extract()`'s parallel path no longer opens a `ProcessPoolExecutor` that can spawn its own. On Windows, a caller script with no `if __name__ == "__main__":` guard made every worker re execute the top level module on import — if that module called `extract()` again at module scope, the worker opened its own pool, whose own guard less children did the same, faster than a per future `BrokenProcessPool` exception could surface and stop it, growing unbounded rather than failing over to sequential extraction. Two checks now run before the pool is opened: unconditionally refuse when already inside a multiprocessing child, and on Windows, decline pre emptively when the caller's own `__main__` module lacks the guard. The guard check itself now looks for the actual guard statement instead of a bare substring match, so a docstring, comment, or unrelated string literal that merely mentions `__main__` is no longer read as a guard that is not really there (#1637, thanks @ray8875). +- Fix: `extract()`'s parallel path no longer opens a `ProcessPoolExecutor` that can spawn its own. On Windows, a caller script with no `if __name__ == "__main__":` guard made every worker re execute the top level module on import — if that module called `extract()` again at module scope, the worker opened its own pool, whose own guard less children did the same, faster than a per future `BrokenProcessPool` exception could surface and stop it, growing unbounded rather than failing over to sequential extraction. Two checks now run before the pool is opened: unconditionally refuse when already inside a multiprocessing child, and on Windows, decline pre emptively when the caller's own `__main__` module lacks the guard. The guard check now parses the caller's source and looks for a real `if` statement comparing `__name__` to `"__main__"`, instead of matching the text — a regex still misread a guard shaped line sitting inside a triple quoted string or a docstring example as a real guard, and rejected a valid parenthesized comparison as no guard at all (#1637, thanks @ray8875). - Feature: Elixir `alias`/`import`/`require`/`use` targets now resolve onto the module's `defmodule` node across files, so the internal module dependency graph is no longer dropped as dangling. Only top-level modules are indexed (a nested `defmodule`, labeled with its bare inner name, cannot capture an unrelated `use ` from another file), and a same-file reference is left unresolved so it cannot clobber the structural `contains` edge (#3603, thanks @ayushcodes10). - Feature: a Rust `self.method()` call now resolves to a method defined on the same type in another file (the common split-`impl`-block layout), pooling methods across every `impl` of one type and refusing to link when two unrelated types share a bare name (#3602, thanks @ayushcodes10). - Feature: a Ruby member call `obj.foo` on a known-type receiver now resolves to a method `foo` inherited from a superclass, including across files, using the same conservative promotion as the implicit-self resolver — a single owning class, matching method kind, and one unambiguous ancestry chain, or it stays dangling (#3585, thanks @oleksii-tumanov). From adab6b40b781e850b49ec1e822f521dd32687e94 Mon Sep 17 00:00:00 2001 From: ayushcodes10 Date: Fri, 18 Sep 2026 14:13:01 +0530 Subject: [PATCH 10/12] Only check the module top level for a real main guard A fresh review round found that ast.walk() finds a guard anywhere in the tree, including one nested inside an unrelated function, class, or dead branch. Such a guard never actually runs at import time and protects nothing, so a caller whose real module scope code is fully unguarded could still be treated as safe, letting the exact fork bomb scenario this check exists to prevent happen anyway. The guard idiom only has its intended effect as a bare top level statement, so only the module's direct top level statements are checked now, not every node anywhere in the source. Co-Authored-By: Claude Sonnet 5 --- graphify/extract.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/graphify/extract.py b/graphify/extract.py index 1e35a3cc5..62e1b83f9 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -6410,6 +6410,13 @@ def _caller_main_lacks_guard() -> bool: a valid but less common form like a parenthesized comparison. The AST does not see string contents as code at all, and is indifferent to formatting, so both gaps close at once. + + Only the module's direct top-level statements are checked, not every + node anywhere in the tree: ``ast.walk`` also finds a guard nested inside + an unrelated function, class, or dead branch, which never executes at + import time and so provides no actual protection at all. The idiom + itself only has its intended effect as a bare top-level statement, so + that is the only place a real guard can be. """ main_file = getattr(sys.modules.get("__main__"), "__file__", None) if not main_file: @@ -6426,7 +6433,7 @@ def _caller_main_lacks_guard() -> bool: return False return not any( isinstance(node, ast.If) and _is_main_guard_test(node.test) - for node in ast.walk(tree) + for node in tree.body ) From c86d00d7fc87712b8dd7221fe00af60afaf1ed06 Mon Sep 17 00:00:00 2001 From: ayushcodes10 Date: Fri, 18 Sep 2026 14:13:01 +0530 Subject: [PATCH 11/12] Add a regression test for the module scope only guard check Covers a guard nested inside an unrelated function, alongside a genuinely unguarded module scope extract() call, the exact combination that let ast.walk() report a guard where there was none. Co-Authored-By: Claude Sonnet 5 --- tests/test_extract.py | 45 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/tests/test_extract.py b/tests/test_extract.py index 2829134f5..306c828c3 100644 --- a/tests/test_extract.py +++ b/tests/test_extract.py @@ -2657,6 +2657,51 @@ def submit(self, *a, **kw): assert spawned["count"] == 1, "a parenthesized comparison is still a real guard" +def test_extract_parallel_declines_pool_for_a_guard_nested_in_an_unrelated_function( + tmp_path, monkeypatch +): + """Review finding: ast.walk() finds a guard anywhere in the tree, including + one nested inside an unrelated function that never runs at import time and + so provides no actual protection at all -- the module-scope extract() call + right below it is genuinely unguarded. Only a real, top-level guard should + count.""" + import concurrent.futures + import multiprocessing + from graphify import extract as extract_mod + + guardless = tmp_path / "runner.py" + guardless.write_text( + "from graphify.extract import extract\n" + "def unrelated_helper():\n" + ' if __name__ == "__main__":\n' + " pass\n" + "extract([])\n", + encoding="utf-8", + ) + + class FakeMain: + __file__ = str(guardless) + + monkeypatch.setattr(sys, "platform", "win32") + monkeypatch.setitem(sys.modules, "__main__", FakeMain()) + monkeypatch.setattr(multiprocessing, "parent_process", lambda: None) + + spawned = {"count": 0} + + def fake_pool(*a, **kw): + spawned["count"] += 1 + raise AssertionError("ProcessPoolExecutor must not be constructed for a guard-less caller") + + monkeypatch.setattr(concurrent.futures, "ProcessPoolExecutor", fake_pool) + + uncached = [(i, FIXTURES / "sample.py") for i in range(25)] + per_file: list = [None] * len(uncached) + + ok = extract_mod._extract_parallel(uncached, per_file, tmp_path, None, len(uncached)) + assert ok is False, "a guard nested inside an unrelated function must not count as real" + assert spawned["count"] == 0, "no pool may be spawned for a genuinely guard-less caller" + + def test_extract_parallel_spawns_pool_when_caller_source_fails_to_parse(tmp_path, monkeypatch): """An unparseable caller (a syntax error, or a non-Python source read as text) means the guard's presence genuinely can't be determined -- this From 55c9157aa85ce24f751847bdc2ce6c26be6b9784 Mon Sep 17 00:00:00 2001 From: ayushcodes10 Date: Fri, 18 Sep 2026 14:13:01 +0530 Subject: [PATCH 12/12] Update changelog entry for issue 1637 again Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index feba50861..674bafa7d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ Full release notes with details on each version: [GitHub Releases](https://githu ## 0.9.63 (2026-09-16) -- Fix: `extract()`'s parallel path no longer opens a `ProcessPoolExecutor` that can spawn its own. On Windows, a caller script with no `if __name__ == "__main__":` guard made every worker re execute the top level module on import — if that module called `extract()` again at module scope, the worker opened its own pool, whose own guard less children did the same, faster than a per future `BrokenProcessPool` exception could surface and stop it, growing unbounded rather than failing over to sequential extraction. Two checks now run before the pool is opened: unconditionally refuse when already inside a multiprocessing child, and on Windows, decline pre emptively when the caller's own `__main__` module lacks the guard. The guard check now parses the caller's source and looks for a real `if` statement comparing `__name__` to `"__main__"`, instead of matching the text — a regex still misread a guard shaped line sitting inside a triple quoted string or a docstring example as a real guard, and rejected a valid parenthesized comparison as no guard at all (#1637, thanks @ray8875). +- Fix: `extract()`'s parallel path no longer opens a `ProcessPoolExecutor` that can spawn its own. On Windows, a caller script with no `if __name__ == "__main__":` guard made every worker re execute the top level module on import — if that module called `extract()` again at module scope, the worker opened its own pool, whose own guard less children did the same, faster than a per future `BrokenProcessPool` exception could surface and stop it, growing unbounded rather than failing over to sequential extraction. Two checks now run before the pool is opened: unconditionally refuse when already inside a multiprocessing child, and on Windows, decline pre emptively when the caller's own `__main__` module lacks the guard. The guard check now parses the caller's source and looks for a real `if` statement comparing `__name__` to `"__main__"`, instead of matching the text — a regex still misread a guard shaped line sitting inside a triple quoted string or a docstring example as a real guard, and rejected a valid parenthesized comparison as no guard at all. It now checks only the module's direct top level statements, since a guard the parser found nested inside an unrelated function, class, or dead branch never actually runs at import time and protects nothing — checking anywhere in the tree could still report a fully unguarded module as safe (#1637, thanks @ray8875). - Feature: Elixir `alias`/`import`/`require`/`use` targets now resolve onto the module's `defmodule` node across files, so the internal module dependency graph is no longer dropped as dangling. Only top-level modules are indexed (a nested `defmodule`, labeled with its bare inner name, cannot capture an unrelated `use ` from another file), and a same-file reference is left unresolved so it cannot clobber the structural `contains` edge (#3603, thanks @ayushcodes10). - Feature: a Rust `self.method()` call now resolves to a method defined on the same type in another file (the common split-`impl`-block layout), pooling methods across every `impl` of one type and refusing to link when two unrelated types share a bare name (#3602, thanks @ayushcodes10). - Feature: a Ruby member call `obj.foo` on a known-type receiver now resolves to a method `foo` inherited from a superclass, including across files, using the same conservative promotion as the implicit-self resolver — a single owning class, matching method kind, and one unambiguous ancestry chain, or it stays dangling (#3585, thanks @oleksii-tumanov).