diff --git a/graphify/extract.py b/graphify/extract.py index 4b9efa732..f7ac9864e 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -6950,7 +6950,9 @@ def _extract_single_file(args: tuple) -> tuple[int, dict]: # (e.g. a transient batch/parallel hiccup). Caching it makes the empty # byte-stable across runs and silently blinds affected/explain to and # through the file (#1666); skipping the write lets a rerun self-heal. - if not bypass_cache and "error" not in result and result.get("nodes"): + # An intentional decline or virtual workspace root (result.get("skipped")) + # is cached so repeat runs do not re-extract and re-report it (#3910). + if not bypass_cache and "error" not in result and (result.get("nodes") or result.get("skipped")): save_cached(path, result, root, cache_root=cache_location) return idx, result @@ -7152,8 +7154,9 @@ def _extract_sequential( bypass_cache = path.suffix in _JS_CACHE_BYPASS_SUFFIXES # XAML boundary anchors on `root` (the corpus), not the cache location. result = _safe_extract_with_xaml_root(extractor, path, root) - # See _extract_single_file: don't cache an anomalous zero-node result (#1666). - if not bypass_cache and "error" not in result and result.get("nodes"): + # See _extract_single_file: don't cache an anomalous zero-node result (#1666), + # but cache intentional declines/virtual manifests carrying `skipped` (#3910). + if not bypass_cache and "error" not in result and (result.get("nodes") or result.get("skipped")): save_cached(path, result, root, cache_root=cache_location) per_file[idx] = result if total_files >= _PROGRESS_INTERVAL: diff --git a/graphify/manifest_ingest.py b/graphify/manifest_ingest.py index a36071930..bffb7bc3b 100644 --- a/graphify/manifest_ingest.py +++ b/graphify/manifest_ingest.py @@ -91,6 +91,8 @@ def extract_package_manifest(path: Path) -> dict[str, Any]: info = _PARSERS[eco](text) except Exception as exc: # noqa: BLE001 — a malformed manifest must not abort extraction return {"nodes": [], "edges": [], "error": f"manifest parse error: {exc}"} + if isinstance(info, dict) and info.get("skipped"): + return {"nodes": [], "edges": [], "skipped": info["skipped"]} if not info or not info.get("name"): return {"nodes": [], "edges": []} @@ -237,6 +239,8 @@ def _parse_cargo(text: str) -> dict | None: # package of its own — emit nothing rather than a fabricated node. ``name`` is # never workspace-inheritable in Cargo, but guard on the type anyway. if not isinstance(name, str) or not name: + if isinstance(data.get("workspace"), dict): + return {"name": None, "skipped": "virtual workspace root Cargo.toml"} return None # ``version`` may be workspace-inherited (``version.workspace = true``), which # parses to a table; keep only a concrete string version. diff --git a/tests/test_manifest_ingest.py b/tests/test_manifest_ingest.py index 6407e477b..5ddd85583 100644 --- a/tests/test_manifest_ingest.py +++ b/tests/test_manifest_ingest.py @@ -136,12 +136,26 @@ def test_cargo_parses_name_version_and_deps(tmp_path): assert {"pkg_serde", "pkg_tokio"} <= deps # inline-table dep keyed by name -def test_cargo_virtual_workspace_manifest_emits_no_package(tmp_path): +def test_cargo_virtual_workspace_manifest_emits_no_package(tmp_path, capsys): # A virtual workspace root has no [package] table, so it declares no package - # of its own — it must not fabricate a node. + # of its own — it must not fabricate a node, and is skipped by design (#3910). p = _write(tmp_path / "Cargo.toml", '[workspace]\nmembers = ["a", "b"]\n') r = extract_package_manifest(p) assert _pkg_nodes(r) == [] + assert r.get("skipped") == "virtual workspace root Cargo.toml" + + # In extract(): must not emit zero-node warning, must not be marked failed, and must be cached + result = extract([p], cache_root=tmp_path) + err = capsys.readouterr().err + assert "zero nodes" not in err + assert result.get("failed_sources") == [] + + # Caching verification: load_cached returns the skipped result + from graphify.cache import load_cached + cached = load_cached(p, tmp_path, cache_root=tmp_path) + assert cached is not None + assert cached.get("skipped") == "virtual workspace root Cargo.toml" + assert cached.get("nodes") == [] def test_cargo_target_conditional_deps_are_collected(tmp_path):