From 3238b3267760054d2e131bc9a05b8aca1b7e98c8 Mon Sep 17 00:00:00 2001 From: Jon Attree Date: Fri, 22 May 2026 06:38:32 -0700 Subject: [PATCH] Exit non-zero when all semantic-extraction chunks fail (#889) If `graphify extract --backend claude` runs without the `anthropic` package installed (pip install graphifyy doesn't pull it in), every semantic chunk fails inside extract_corpus_parallel. The per-chunk errors print to stderr but the function returns the empty merged accumulator anyway, so extract proceeds to write an AST-only graph.json and exit 0. CI that checks exit status sees success even though the requested semantic pass produced no nodes. Track per-chunk success via the existing on_chunk_done callback, which only fires after a chunk succeeds. If fresh extraction was requested (uncached_paths non-empty) and zero chunks completed, abort before the merge/cluster/write phase with exit 1 and a message naming the backend. The same shape covers other backends with optional SDK deps (openai, google-generativeai). Cached-only runs are unaffected: uncached_paths is empty and the guard does not fire. Tests in tests/test_extract_cli.py simulate the all-failed and one-succeeded paths by patching extract_corpus_parallel directly. --- graphify/__main__.py | 20 ++++++- tests/test_extract_cli.py | 123 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 141 insertions(+), 2 deletions(-) create mode 100644 tests/test_extract_cli.py diff --git a/graphify/__main__.py b/graphify/__main__.py index 4b08c2696..17f3e447f 100644 --- a/graphify/__main__.py +++ b/graphify/__main__.py @@ -2901,9 +2901,12 @@ def main() -> None: # Minimal progress callback so the CLI is no longer silent # during long local-inference runs (issue #792 addendum). - _total_chunks = {"n": 0} + # Also track per-chunk success so we can fail loudly when + # every chunk errors (e.g. missing backend SDK package). + _chunk_stats = {"total": 0, "succeeded": 0} def _progress(idx: int, total: int, _result: dict) -> None: - _total_chunks["n"] = total + _chunk_stats["total"] = total + _chunk_stats["succeeded"] += 1 print( f"[graphify extract] chunk {idx + 1}/{total} done", flush=True, @@ -2924,6 +2927,19 @@ def main() -> None: file=sys.stderr, ) fresh = {"nodes": [], "edges": [], "hyperedges": [], "input_tokens": 0, "output_tokens": 0} + + # on_chunk_done only fires after a chunk succeeds. If fresh + # semantic extraction was requested and no chunks completed, + # fail instead of writing an AST-only graph with exit 0. + if uncached_paths and _chunk_stats["succeeded"] == 0: + print( + f"[graphify extract] error: all semantic chunks failed " + f"for backend '{backend}' ({len(uncached_paths)} uncached files) - " + f"see per-chunk errors above. If you see 'requires the X package', " + f"run `pip install X` and retry.", + file=sys.stderr, + ) + sys.exit(1) try: _save_semantic_cache( fresh.get("nodes", []), diff --git a/tests/test_extract_cli.py b/tests/test_extract_cli.py new file mode 100644 index 000000000..6998bcce9 --- /dev/null +++ b/tests/test_extract_cli.py @@ -0,0 +1,123 @@ +"""Tests for `graphify extract` CLI dispatch path in graphify.__main__.""" +from __future__ import annotations + +import pytest + +import graphify.__main__ as mainmod + + +def _make_corpus(tmp_path): + """Minimal corpus: one Go code file + one Markdown doc. + + Both file types are needed so semantic extraction is requested + (docs path triggers the LLM step we want to assert against). + """ + (tmp_path / "main.go").write_text("package main\nfunc main() {}\n") + (tmp_path / "README.md").write_text("# Notes\nThe main function entry point.\n") + return tmp_path + + +def test_extract_exits_nonzero_when_all_semantic_chunks_fail( + monkeypatch, tmp_path, capsys +): + """When every semantic chunk errors (e.g. backend SDK not installed), + the CLI must exit non-zero instead of silently writing an AST-only graph. + + The bug this guards: `pip install graphifyy` doesn't pull in `anthropic`, + so `graphify extract --backend claude` would print per-chunk errors and + still exit 0 with a graph.json. Callers checking exit status saw success. + """ + corpus = _make_corpus(tmp_path) + out_dir = tmp_path / "out" + + # Stub the API-key check so the backend gate doesn't reject before we + # reach the semantic-extraction step. + monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-test-fake-key") + + # Patch extract_corpus_parallel to simulate "all chunks failed": + # return an empty merged accumulator without ever invoking on_chunk_done. + # This matches the real behavior of extract_corpus_parallel when every + # chunk raises (the per-chunk failures print to stderr and the loop + # continues without calling the success callback). + def _all_chunks_failed(paths, **kwargs): + return { + "nodes": [], + "edges": [], + "hyperedges": [], + "input_tokens": 0, + "output_tokens": 0, + } + + monkeypatch.setattr( + "graphify.llm.extract_corpus_parallel", _all_chunks_failed + ) + monkeypatch.setattr(mainmod, "_check_skill_version", lambda _: None) + monkeypatch.setattr( + mainmod.sys, + "argv", + ["graphify", "extract", str(corpus), "--backend", "claude", + "--out", str(out_dir)], + ) + + with pytest.raises(SystemExit) as exc_info: + mainmod.main() + + assert exc_info.value.code == 1, ( + f"expected exit code 1 when all semantic chunks fail, " + f"got {exc_info.value.code}" + ) + + stderr = capsys.readouterr().err + assert "all semantic chunks failed" in stderr + assert "claude" in stderr + + # No graph.json should have been written - the failure must abort before + # the merge/cluster/write phase, not after. + assert not (out_dir / "graphify-out" / "graph.json").exists(), ( + "graph.json must not be written when semantic extraction fails" + ) + + +def test_extract_succeeds_when_at_least_one_chunk_completes( + monkeypatch, tmp_path +): + """Sanity counter-test: a successful chunk run keeps exit 0. Confirms the + new guard only fires on the all-failed path, not on every extract.""" + corpus = _make_corpus(tmp_path) + out_dir = tmp_path / "out" + monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-test-fake-key") + + def _one_chunk_succeeded(paths, **kwargs): + on_chunk = kwargs.get("on_chunk_done") + if on_chunk: + on_chunk(0, 1, {"nodes": [], "edges": [], "hyperedges": []}) + return { + "nodes": [], + "edges": [], + "hyperedges": [], + "input_tokens": 100, + "output_tokens": 50, + } + + monkeypatch.setattr( + "graphify.llm.extract_corpus_parallel", _one_chunk_succeeded + ) + monkeypatch.setattr(mainmod, "_check_skill_version", lambda _: None) + monkeypatch.setattr( + mainmod.sys, + "argv", + ["graphify", "extract", str(corpus), "--backend", "claude", + "--out", str(out_dir)], + ) + + # extract may still raise SystemExit at the end (clean exit code 0) + # depending on platform; accept either no exception or SystemExit(0). + try: + mainmod.main() + except SystemExit as exc: + assert exc.code in (None, 0), f"unexpected exit code {exc.code}" + + # graph.json should exist on the happy path + assert (out_dir / "graphify-out" / "graph.json").exists(), ( + "graph.json must be written on the happy path" + )