Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions graphify/extract.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
79 changes: 79 additions & 0 deletions tests/test_incremental.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down