diff --git a/graphify/cli.py b/graphify/cli.py index 04f7c5ff03..24b4890cd8 100644 --- a/graphify/cli.py +++ b/graphify/cli.py @@ -81,6 +81,31 @@ ) +UNCOVERED_FILES_NAME = ".graphify_uncovered_files.json" + + +def _write_uncovered_files(graphify_out: Path, files: "list[str]") -> "Path | None": + """Record the files a semantic extraction dispatched but produced no nodes for. + + The warning names five of them and counts the rest, and the full list was + computed and then dropped, so recovering it meant diffing the graph against + a filesystem walk (#3574). An empty list removes the file rather than + leaving one that describes an earlier run. + + Returns the path written, or None. Never raises: this is a diagnostic, and + an output directory that refuses it must not cost the extraction. + """ + target = Path(graphify_out) / UNCOVERED_FILES_NAME + try: + if not files: + target.unlink(missing_ok=True) + return None + target.write_text(json.dumps(sorted(files), indent=2) + "\n", encoding="utf-8") + except OSError: + return None + return target + + def _default_graph_path() -> str: return str(Path(_GRAPHIFY_OUT) / "graph.json") @@ -4000,14 +4025,17 @@ def _progress(idx: int, total: int, _result: dict) -> None: # fraction of the graph, so it must arm the guard exactly like a # crashed chunk does. --allow-partial still overrides. _omitted_files = list(fresh.get("uncovered_files") or []) + _uncovered_path = _write_uncovered_files(graphify_out, _omitted_files) if _omitted_files or _partial_semantic_files: _extraction_incomplete = True + _where = f" The full list is in {_uncovered_path}." if _uncovered_path else "" print( f"[graphify extract] semantic extraction is incomplete: " f"{len(_omitted_files)} dispatched file(s) produced no nodes and " f"{len(_partial_semantic_files)} came back truncated or hollow. " f"The shrink guard stays armed for this write; pass " - f"--allow-partial to overwrite a larger existing graph anyway.", + f"--allow-partial to overwrite a larger existing graph anyway." + f"{_where}", file=sys.stderr, ) try: diff --git a/tests/test_hollow_chunks_arm_shrink_guard.py b/tests/test_hollow_chunks_arm_shrink_guard.py index 01d6c2082f..2545eac0ea 100644 --- a/tests/test_hollow_chunks_arm_shrink_guard.py +++ b/tests/test_hollow_chunks_arm_shrink_guard.py @@ -13,6 +13,8 @@ import pytest +from pathlib import Path + import graphify.__main__ as mainmod @@ -116,3 +118,54 @@ def _refuse(G, communities, output_path, *, force=False, **kwargs): code = _run() assert code not in (None, 0) assert not (out_dir / "graphify-out" / "manifest.json").exists() + + +# --- the omitted files must be recoverable, not just counted (#3574) --- + + +def _uncovered_list(out_dir): + from graphify.cli import UNCOVERED_FILES_NAME + + return out_dir / "graphify-out" / UNCOVERED_FILES_NAME + + +def test_the_omitted_files_are_written_where_a_user_can_read_them(monkeypatch, tmp_path, capsys): + """The warning names five and counts the rest. + + The full list was computed into ``uncovered_files`` and then read only for + its length, so recovering it meant diffing the graph against a filesystem + walk. + """ + import json + + _record_force(monkeypatch) + out_dir = _arm(monkeypatch, tmp_path, uncovered=("GUIDE.md",)) + _run() + + listed = json.loads(_uncovered_list(out_dir).read_text(encoding="utf-8")) + assert [Path(p).name for p in listed] == ["GUIDE.md"] + assert str(_uncovered_list(out_dir)) in capsys.readouterr().err + + +def test_a_covered_run_clears_an_earlier_list(monkeypatch, tmp_path): + """A leftover list describes files that are covered now, which is worse than none.""" + _record_force(monkeypatch) + out_dir = _arm(monkeypatch, tmp_path) + stale = _uncovered_list(out_dir) + stale.parent.mkdir(parents=True, exist_ok=True) + stale.write_text('["plugin/gone.php"]', encoding="utf-8") + + _run() + + assert not stale.exists() + + +def test_the_list_never_costs_the_extraction(tmp_path, monkeypatch): + """A directory that refuses the write loses the diagnostic, not the run.""" + from graphify.cli import _write_uncovered_files + + def _refuse(self, *args, **kwargs): + raise PermissionError(f"read-only: {self}") + + monkeypatch.setattr(Path, "write_text", _refuse) + assert _write_uncovered_files(tmp_path, ["a.php"]) is None