mirror of
https://github.com/safishamsi/graphify.git
synced 2026-09-24 14:35:46 +00:00
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.
This commit is contained in:
+18
-2
@@ -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", []),
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
Reference in New Issue
Block a user