From 2ccd289e9ecfbda365113c33c10a625efe6993e2 Mon Sep 17 00:00:00 2001 From: L4XB Date: Tue, 15 Sep 2026 19:00:42 +0200 Subject: [PATCH] feat(extract): write the omitted-file list where a user can read it The incomplete-extraction warning names five files and counts the rest, and the full list was already sitting in `uncovered_files` on the merged result: `cli.py` read it only for its length and then dropped it. Recovering the rest meant diffing the graph against a filesystem walk. Write it to graphify-out/.graphify_uncovered_files.json and say so in the warning. A run with everything covered removes the file rather than leaving one that describes an earlier run, and an output directory that refuses the write costs the diagnostic rather than the extraction. --- graphify/cli.py | 30 ++++++++++- tests/test_hollow_chunks_arm_shrink_guard.py | 53 ++++++++++++++++++++ 2 files changed, 82 insertions(+), 1 deletion(-) 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