diff --git a/graphify/extract.py b/graphify/extract.py index 30f31d329..d53dc8b29 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -4769,6 +4769,58 @@ def extract( all_nodes.extend(result.get("nodes", [])) all_edges.extend(result.get("edges", [])) all_raw_calls.extend(result.get("raw_calls", [])) + + # #2230: Incremental runs pass only the CHANGED files in `paths`. Symbol + # resolution (augment + raw_calls) indexes nodes from this run alone, so + # edges like `A imports/calls B.helper` vanish when B is unchanged — the + # merge then correctly drops A's old edges and nothing regenerates them. + # Fix: discover one hop of in-root import targets via `target_file` (same + # set #2169 remaps), load them for resolution FACTS only, let them flow + # through augment / id-remap / callable_nids, then strip their ownership + # before return so the merge keeps B's existing graph chunk. Omit context + # raw_calls — one hop is enough and would otherwise pull a second hop. + _batch_resolved: set[Path] = set() + for _p in paths: + try: + _batch_resolved.add(_p.resolve()) + except (OSError, RuntimeError): + pass + # Collect neighbor files stamped on this batch's edges (not already in + # the batch, in-root, on disk, and extractable). + _context_files: set[Path] = set() + for _e in all_edges: + _tf = _e.get("target_file") + if not _tf: + continue + try: + _tp = Path(_tf).resolve() + except (OSError, RuntimeError): + continue + if _tp in _batch_resolved or _tp in _context_files: + continue + try: + _tp.relative_to(root) + if not _tp.is_file() or _get_extractor(_tp) is None: + continue + except (ValueError, OSError): + continue + _context_files.add(_tp) + + for _i, _ctx in enumerate(_context_files): + # Same cache/bypass path as a normal batch file; we deliberately do + # not extend all_raw_calls from these results. + _, _ctx_result = _extract_single_file( + (_i, str(_ctx), str(root), str(cache_location)) + ) + # Keep only items with a source_file — sourceless stubs (e.g. an + # Exception base) would slip past the ownership filter at return. + all_nodes.extend( + n for n in _ctx_result.get("nodes", []) if n.get("source_file") + ) + all_edges.extend( + e for e in _ctx_result.get("edges", []) if e.get("source_file") + ) + # Function / method / class def ids for the cross-file indirect_call callable # guard. Built from the `_callable` node marker AFTER the id-remap / disambiguation # passes below (which rewrite node ids), so it can never go stale — see the @@ -5722,6 +5774,16 @@ def _canon(nid: str) -> str: for e in all_edges: e["_origin"] = "ast" + # #2230: Context nodes/edges were only borrowed for resolution. Drop them + # now (by relativized source_file) so this result owns only the batch + # files; regenerated cross-file edges may still TARGET neighbor ids, and + # the incremental merge supplies the neighbor's own nodes from the + # existing graph. + if _context_files: + _context_sfs = {p.relative_to(root).as_posix() for p in _context_files} + all_nodes = [n for n in all_nodes if (n.get("source_file") or "") not in _context_sfs] + all_edges = [e for e in all_edges if (e.get("source_file") or "") not in _context_sfs] + return { "nodes": all_nodes, "edges": all_edges, diff --git a/tests/test_incremental.py b/tests/test_incremental.py index 62c120325..e733ed3f2 100644 --- a/tests/test_incremental.py +++ b/tests/test_incremental.py @@ -308,6 +308,85 @@ def test_incremental_md_reference_target_canonicalizes(tmp_path): assert "target_file" not in e, e +def test_incremental_extract_regenerates_neighbor_symbol_edges(tmp_path): + """#2230: extract([A]) alone must still emit A→B imports/calls. + + Without context-file facts, symbol resolution only sees A's nodes and + those edges are missing from the incremental chunk (then dropped by + merge). Also assert B's owned nodes/edges are not returned — ownership + must stay with the unchanged file for the merge to carry them forward. + """ + from graphify.extract import extract + + tmp = Path(os.path.realpath(tmp_path)) + pkg = tmp / "pkg" + pkg.mkdir() + (pkg / "b.py").write_text( + "class BadData(Exception):\n pass\n\ndef helper():\n return 1\n", + encoding="utf-8", + ) + a = pkg / "a.py" + a.write_text( + "from .b import BadData, helper\n\ndef use():\n helper()\n raise BadData()\n", + encoding="utf-8", + ) + + def _a_edges(result): + return sorted( + (e.get("source"), e.get("target"), e.get("relation"), e.get("confidence")) + for e in result["edges"] + if str(e.get("source_file", "")).replace("\\", "/").endswith("a.py") + ) + + full = extract([a, pkg / "b.py"], cache_root=tmp, root=tmp) + inc = extract([a], cache_root=tmp, root=tmp) + assert _a_edges(inc) == _a_edges(full) + assert not any( + str(x.get("source_file", "")).replace("\\", "/").endswith("b.py") + for x in inc["nodes"] + inc["edges"] + ) + + +@pytest.mark.parametrize("no_cluster", [True, False], ids=["no-cluster", "clustered"]) +def test_incremental_cli_neighbor_edge_parity(tmp_path, no_cluster): + """#2230: full extract → touch A → incremental must keep the same edges. + + Covers both merge paths (clustered and --no-cluster); the bug is in + extract() itself and hits both identically. + """ + tmp = Path(os.path.realpath(tmp_path)) + proj = tmp / "proj" + pkg = proj / "pkg" + pkg.mkdir(parents=True) + (pkg / "b.py").write_text( + "class BadData(Exception):\n pass\n\ndef helper():\n return 1\n", + encoding="utf-8", + ) + a = pkg / "a.py" + a.write_text( + "from .b import BadData, helper\n\ndef use():\n helper()\n raise BadData()\n", + encoding="utf-8", + ) + + flags = ["--code-only"] + (["--no-cluster"] if no_cluster else []) + first = _run(["extract", str(proj), *flags], tmp) + assert first.returncode == 0, first.stderr + gj = proj / "graphify-out" / "graph.json" + + def _keys(): + return sorted( + (e.get("source"), e.get("target"), e.get("relation"), e.get("confidence")) + for e in _edges(gj) + ) + + before = _keys() + a.write_text(a.read_text(encoding="utf-8") + "\n# touched\n", encoding="utf-8") + second = _run(["extract", str(proj), *flags], tmp) + assert second.returncode == 0, second.stderr + assert "incremental scan" in second.stdout.lower(), second.stdout + assert _keys() == before + + def test_update_prunes_a_removed_imports_edge(tmp_path): """#1521: when an import is deleted from a file, `graphify update` must prune the edge it produced — preserving it (keyed only on endpoint membership) left a