diff --git a/CHANGELOG.md b/CHANGELOG.md
index 03d264a..077647f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,7 +2,16 @@
Full release notes with details on each version: [GitHub Releases](https://github.com/safishamsi/graphify/releases)
-## 0.9.34 (unreleased)
+## 0.9.35 (unreleased)
+
+- Fix: the `build_merge` #479 shrink guard is no longer effectively dead (#2497, thanks @sortakool). It read the post-replace node count, so a broken partial re-extract could silently destroy nodes without tripping the guard, and the guard was skipped entirely under `prune_sources`. The guard now diffs the on-disk baseline by node identity and refuses any loss from a source that was neither re-extracted nor pruned this run (active even under `prune_sources`, skipped only under `dedup`), and reports how many nodes a re-extract replaced.
+- Fix: `build_merge`/`merge_raw_extraction` `prune_sources` now prunes correctly when given absolute paths under a non-standard layout, deriving the scan root by suffix-matching stored source paths, and warns (instead of reporting "already clean") when a prune matches nothing (#2446, thanks @AI-invest).
+- Fix: `graphify update` now removes newly-ignored files from an existing graph (#2495, thanks @alisson-acioli). A file that was added to `.graphifyignore`/`--exclude` (or a skip rule) is evicted even though it still exists on disk; `.gitignore`-driven eviction applies on an explicit full `update`. Files that merely changed are still preserved, and a file that leaves the corpus without matching any live ignore rule stays (fail-closed, #1795).
+- Fix: a Java local class and a same-named external annotation (e.g. a local `class Component` and Spring's `@Component`) no longer collapse into one node (#2504, thanks @te7ina-honey). The Java type resolver now runs before the unique-label stub rewire and parks an imported-but-external type on its fully-qualified name, and cross-file import resolution checks the package. In-corpus annotation resolution is unchanged.
+- Fix: `graphify callflow` now respects edge direction, so the caller/callee columns are correct (#2508, thanks @Tomaskobel). The call-flow HTML loads the graph directed and recovers direction from the stored `_src`/`_tgt` markers (consistent with the `path` fix), and indirect calls are counted.
+- Fix: relational-intent verbs in a `query` ("calls", "uses", "extends", ...) no longer seat spurious seeds (#2507, thanks @filipechagas). Such a verb is excluded from the per-term seed guarantee, so a decoy matching only the verb no longer becomes a traversal root, while a verb that is a genuine symbol name can still be seeded on merit.
+
+## 0.9.34 (2026-08-05)
- Fix: C# receiver typing no longer drops a true call when a same-named variable is declared untypeably elsewhere in the method (#2472, thanks @JensD-git). Receiver types are now tracked per lexical declaration scope and resolved by the call's position, so a typed `static` local-function parameter keeps resolving even when an `out var` reuses the name in the enclosing body. This fixes a regression from 0.9.32 (#2346). Cross-method independence (#2299) and field-conflict poisoning are unchanged; an `out var` receiver itself remains untyped.
- Fix: `graphify path` (and the MCP `shortest_path` tool) now respect edge direction by default instead of running on an undirected view, so a returned path no longer traverses edges backwards (#2487, thanks @luliaz0601). Direction is recovered from the stored `_src`/`_tgt` markers. Pass `--undirected` (CLI) or `undirected=true` (MCP) to search ignoring direction; when no directed path exists the command says so instead of silently returning a reversed one.
diff --git a/graphify/callflow_html.py b/graphify/callflow_html.py
index dabb299..3c8a34b 100644
--- a/graphify/callflow_html.py
+++ b/graphify/callflow_html.py
@@ -233,7 +233,16 @@ def _node_link_payload(data: dict) -> tuple[list, list] | None:
# though the shape check above accepts "edges" (#2212).
from graphify.paths import load_node_link_graph
- graph = load_node_link_graph(data)
+ # Force directed/multigraph so the stored caller->callee direction and
+ # parallel edges survive the round-trip; mirrors affected.py,
+ # serve.py and cli.py (#1174) and the directed-view fix for
+ # path/shortest_path (#2487, #2309). graph.json is written with
+ # "directed": false for backward compatibility, so without this
+ # networkx returns an undirected Graph and edge orientation becomes
+ # arbitrary -- which silently swaps the Caller and Callee columns of
+ # the call table and drops parallel edges. The _src/_tgt override
+ # below still wins on legacy marker files.
+ graph = load_node_link_graph({**data, "directed": True, "multigraph": True})
except Exception:
return None
@@ -1207,19 +1216,38 @@ def format_node_refs(node_ids: set, node_by_id: dict, lang: str, empty_text: str
return "
".join(parts)
-def generate_call_table_rows(nodes: list, section_edges: list, lang: str) -> str:
- """Generate call table row scaffolding for a section's nodes."""
+def generate_call_table_rows(
+ nodes: list,
+ section_edges: list,
+ lang: str,
+ all_edges: list | None = None,
+ all_nodes: list | None = None,
+) -> str:
+ """Generate call table row scaffolding for a section's nodes.
+
+ The Caller/Callee columns make a claim about the whole graph ("External
+ entry / no inbound edge"), so they must be computed from the whole graph.
+ Built from ``section_edges`` alone they only see edges whose *both*
+ endpoints sit in this section, so a node called from anywhere else is
+ mislabelled an entry point. Section coverage makes that the common case
+ rather than a corner case: only ``max_sections`` communities are rendered,
+ so most callers are not in any rendered section at all.
+
+ ``all_edges``/``all_nodes`` are the full graph; ``all_nodes`` also lets
+ out-of-section callers render as labels instead of raw node ids. Both
+ default to None, preserving the previous behaviour for other callers.
+ """
if not nodes:
return ""
# Build source/target lookup from edges
- node_by_id = {n.get("id"): n for n in nodes}
+ node_by_id = {n.get("id"): n for n in (all_nodes or nodes)}
callers = defaultdict(set)
callees = defaultdict(set)
- for e in section_edges:
+ for e in (all_edges if all_edges is not None else section_edges):
src = e.get("source", "")
tgt = e.get("target", "")
- if e.get("relation") in ("calls", "imports", "imports_from", "uses", "method"):
+ if e.get("relation") in ("calls", "imports", "imports_from", "uses", "method", "indirect_call"):
callers[tgt].add(src)
callees[src].add(tgt)
@@ -1743,7 +1771,7 @@ def write_callflow_html(
{callee_header} |
{desc_header} |
-{generate_call_table_rows(sec_nodes, sec_edges, lang)}
+{generate_call_table_rows(sec_nodes, sec_edges, lang, edges, nodes)}
{generate_section_cards(sec, sec_nodes, sec_edges, lang)}
diff --git a/graphify/serve.py b/graphify/serve.py
index 1f5e993..3b205d8 100644
--- a/graphify/serve.py
+++ b/graphify/serve.py
@@ -722,8 +722,31 @@ def _pick_seeds(
return seeds
+# Verb-shaped tokens that express the RELATION a query asks about ("who calls
+# X", "what uses Y") rather than a symbol to look up. `_query_terms` keeps them
+# on purpose (a corpus can legitimately define an identifier named `calls`, see
+# #1597), but they must not be handed a guaranteed seed slot in `_pick_seeds`:
+# an incidental prefix match (e.g. "calls" prefixing `.callStoreWithAmount()`)
+# would otherwise seat an unrelated decoy as a BFS root (#2507). Demotion
+# happens at the `_query_graph_text` call site, so `_score_query`'s ranking —
+# where such a verb can still win a seat on merit via the gap window — is
+# untouched. Deliberately verbs only; relation NOUNS (module, field, return)
+# stay eligible for the guarantee.
+_RELATIONAL_INTENT_TERMS: frozenset[str] = frozenset({
+ "call", "calls", "called", "caller", "callers",
+ "invoke", "invokes", "invoked",
+ "use", "uses", "used", "using",
+ "import", "imports", "imported",
+ "export", "exports", "exported",
+ "extend", "extends", "extended",
+ "implement", "implements", "implemented",
+ "depend", "depends",
+ "reference", "references", "referenced",
+})
+
+
_CONTEXT_HINTS: tuple[tuple[str, tuple[str, ...]], ...] = (
- ("call", ("call", "calls", "called", "invoke", "invokes", "invoked")),
+ ("call", ("call", "calls", "called", "caller", "callers", "invoke", "invokes", "invoked")),
("import", ("import", "imports", "imported", "module", "modules")),
("field", ("field", "fields", "member", "members", "property", "properties")),
("parameter_type", ("parameter", "parameters", "param", "params", "argument", "arguments")),
@@ -1105,7 +1128,20 @@ def _query_graph_text(
# time; on a 100k-node, three-term benchmark ~71% of scoring time was
# spent in those redundant per-term passes.
qs = _score_query(G, terms, collect_per_term_seeds=True)
- start_nodes = _pick_seeds(qs.ranked, G=G, best_seed_by_term=qs.best_seed_by_term)
+ # Relational-intent verbs ("calls", "uses", ...) describe the relation the
+ # question asks about, not a symbol to seed from; drop them from the
+ # per-term seed GUARANTEE so an incidental verb match cannot seat a decoy
+ # BFS root (#2507). They keep their place in `qs.ranked`, so a genuine
+ # identifier named after a verb can still win a seat on merit via the gap
+ # window — and when the query consists ONLY of intent words (bare "calls"),
+ # the guarantee is left intact so such an identifier stays reachable.
+ best_seed_by_term = qs.best_seed_by_term
+ intent = {t for t in best_seed_by_term if t in _RELATIONAL_INTENT_TERMS}
+ if intent and any(t not in _RELATIONAL_INTENT_TERMS for t in terms):
+ best_seed_by_term = {
+ t: nid for t, nid in best_seed_by_term.items() if t not in intent
+ }
+ start_nodes = _pick_seeds(qs.ranked, G=G, best_seed_by_term=best_seed_by_term)
if not start_nodes:
return "No matching nodes found."
resolved_filters, filter_source = _resolve_context_filters(question, context_filters)
diff --git a/pyproject.toml b/pyproject.toml
index 7b8e715..6288b9c 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "graphifyy"
-version = "0.9.34"
+version = "0.9.35"
description = "AI coding assistant skill (Claude Code, CodeBuddy, Codex, OpenCode, Kilo Code, Cursor, Gemini CLI, Aider, OpenClaw, Factory Droid, Trae, Hermes, Kiro, Pi, Devin CLI, Google Antigravity) - turn any folder of code, docs, papers, images, or videos into a queryable knowledge graph"
readme = "README.md"
license = "Apache-2.0"
diff --git a/tests/test_callflow_html.py b/tests/test_callflow_html.py
index 9605c9b..e42d1b4 100644
--- a/tests/test_callflow_html.py
+++ b/tests/test_callflow_html.py
@@ -185,3 +185,163 @@ def test_load_graph_rejects_oversized_file(monkeypatch, tmp_path):
with pytest.raises(SystemExit) as excinfo:
load_graph(graph_path)
assert "exceeds" in str(excinfo.value)
+
+
+def _write_graph(tmp_path: Path, nodes: list, links: list) -> Path:
+ graph_path = tmp_path / "graph.json"
+ graph_path.write_text(
+ json.dumps(
+ {
+ "directed": False,
+ "multigraph": False,
+ "graph": {},
+ "nodes": nodes,
+ "links": links,
+ "hyperedges": [],
+ }
+ ),
+ encoding="utf-8",
+ )
+ return graph_path
+
+
+def test_load_graph_preserves_edge_direction(tmp_path):
+ """#1174/#2487: graph.json is written with "directed": false, so the
+ node-link parser must be told otherwise or networkx returns an undirected
+ Graph and caller->callee orientation becomes arbitrary."""
+ from graphify.callflow_html import generate_call_table_rows, load_graph
+
+ # Callee node inserted first: an undirected round-trip deterministically
+ # yields the flipped (api, run) arc, so this fails without the forced
+ # directed load.
+ graph_path = _write_graph(
+ tmp_path,
+ nodes=[
+ {"id": "api", "label": "ApiClient", "source_file": "src/api.py", "file_type": "code", "community": 0},
+ {"id": "run", "label": "run()", "source_file": "src/main.py", "file_type": "code", "community": 0},
+ ],
+ links=[
+ {"source": "run", "target": "api", "relation": "calls", "confidence": "EXTRACTED", "confidence_score": 1.0},
+ ],
+ )
+ nodes, edges, _hyper, _meta = load_graph(graph_path)
+
+ directed = {(e["source"], e["target"]) for e in edges}
+ assert ("run", "api") in directed
+ assert ("api", "run") not in directed, "edge direction was lost (undirected load)"
+
+ api_node = [n for n in nodes if n["id"] == "api"]
+ rows = generate_call_table_rows(api_node, edges, "en", edges, nodes)
+ assert "run()" in rows, "api's Caller column should list run()"
+ assert "External entry" not in rows
+ assert "No direct outbound edge" in rows, "api has no callees"
+
+
+def test_load_graph_legacy_markers_override_arc_order(tmp_path):
+ """Legacy graph.json files carry _src/_tgt markers on each link; they must
+ override the stored arc even under the forced-directed load."""
+ from graphify.callflow_html import generate_call_table_rows, load_graph
+
+ graph_path = _write_graph(
+ tmp_path,
+ nodes=[
+ {"id": "A", "label": "alpha()", "source_file": "src/a.py", "file_type": "code", "community": 0},
+ {"id": "B", "label": "beta()", "source_file": "src/b.py", "file_type": "code", "community": 0},
+ ],
+ links=[
+ {"source": "B", "target": "A", "_src": "A", "_tgt": "B", "relation": "calls", "confidence": "EXTRACTED", "confidence_score": 1.0},
+ ],
+ )
+ nodes, edges, _hyper, _meta = load_graph(graph_path)
+
+ assert len(edges) == 1
+ assert (edges[0]["source"], edges[0]["target"]) == ("A", "B")
+
+ b_node = [n for n in nodes if n["id"] == "B"]
+ rows = generate_call_table_rows(b_node, edges, "en", edges, nodes)
+ assert "alpha()" in rows, "B's Caller column should list alpha()"
+ assert "External entry" not in rows
+
+
+def test_call_table_counts_indirect_call_relation():
+ """indirect_call edges are real callers (affected.py's relation set); a
+ node reached only via indirect_call must not be an "External entry"."""
+ from graphify.callflow_html import generate_call_table_rows
+
+ nodes = [
+ {"id": "A", "label": "alpha()", "source_file": "src/a.py", "file_type": "code"},
+ {"id": "B", "label": "beta()", "source_file": "src/b.py", "file_type": "code"},
+ ]
+ edges = [{"source": "A", "target": "B", "relation": "indirect_call"}]
+
+ rows = generate_call_table_rows([nodes[1]], edges, "en", edges, nodes)
+ assert "External entry" not in rows
+ assert "alpha()" in rows, "indirect caller should appear in the Caller column"
+
+
+def test_load_graph_preserves_parallel_edges(tmp_path):
+ """Forcing multigraph keeps parallel edges between the same endpoints, and
+ the caller set still dedupes so the table does not double-count."""
+ from graphify.callflow_html import generate_call_table_rows, load_graph
+
+ graph_path = _write_graph(
+ tmp_path,
+ nodes=[
+ {"id": "A", "label": "alpha()", "source_file": "src/a.py", "file_type": "code", "community": 0},
+ {"id": "B", "label": "beta()", "source_file": "src/b.py", "file_type": "code", "community": 0},
+ ],
+ links=[
+ {"source": "A", "target": "B", "relation": "calls", "confidence": "EXTRACTED", "confidence_score": 1.0},
+ {"source": "A", "target": "B", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0},
+ ],
+ )
+ nodes, edges, _hyper, _meta = load_graph(graph_path)
+
+ parallel = [e for e in edges if (e["source"], e["target"]) == ("A", "B")]
+ assert len(parallel) == 2, "parallel edges were collapsed (multigraph forcing lost)"
+ assert {e["relation"] for e in parallel} == {"calls", "references"}
+
+ b_node = [n for n in nodes if n["id"] == "B"]
+ rows = generate_call_table_rows(b_node, edges, "en", edges, nodes)
+ assert rows.count("alpha()") == 1, "caller set should dedupe parallel edges"
+
+
+def test_call_table_caller_column_sees_other_sections(tmp_path):
+ """A node called from a different section is not an "External entry".
+
+ ``export`` is used by ``api``, which lives in another community. Computing
+ the Caller column from section-local edges alone mislabels it an entry
+ point -- a whole-graph claim made from partial data.
+ """
+ from graphify.callflow_html import generate_call_table_rows, load_graph
+
+ out = _make_graphify_out(tmp_path)
+ nodes, edges, _hyper, _meta = load_graph(out / "graph.json")
+ export_node = [n for n in nodes if n["id"] == "export"]
+
+ rows = generate_call_table_rows(export_node, [], "en", edges, nodes)
+ assert "External entry" not in rows
+ assert "ApiClient" in rows, "cross-section caller should render as a label"
+
+
+def test_call_table_rows_without_whole_graph_params_unchanged(tmp_path):
+ """The new all_edges/all_nodes params default to None, so existing
+ three-argument callers keep the previous section-local behaviour."""
+ from graphify.callflow_html import generate_call_table_rows, load_graph
+
+ out = _make_graphify_out(tmp_path)
+ nodes, edges, _hyper, _meta = load_graph(out / "graph.json")
+ section_nodes = [n for n in nodes if n["community"] == 0]
+ section_ids = {n["id"] for n in section_nodes}
+ section_edges = [e for e in edges if e["source"] in section_ids and e["target"] in section_ids]
+
+ legacy = generate_call_table_rows(section_nodes, section_edges, "en")
+ # Section-local fixture: the whole graph *is* the section, so the new
+ # path must be byte-identical to the legacy three-argument call.
+ explicit = generate_call_table_rows(section_nodes, section_edges, "en", section_edges, section_nodes)
+ assert legacy == explicit
+
+ # And without the params, out-of-section callers stay invisible (the old
+ # semantics other call sites may rely on).
+ export_node = [n for n in nodes if n["id"] == "export"]
+ assert "External entry" in generate_call_table_rows(export_node, section_edges, "en")
diff --git a/tests/test_serve.py b/tests/test_serve.py
index eb4a989..881247c 100644
--- a/tests/test_serve.py
+++ b/tests/test_serve.py
@@ -921,6 +921,86 @@ def test_query_seeds_from_identifier_not_noise():
assert "ServiceClient" in text
+# --- relational-intent verbs must not seat decoy seeds (#2507) ---
+
+def _make_callers_graph() -> nx.Graph:
+ """A service, three callers wired via context='call' edges, and a decoy
+ whose tokenized label ('callstorewithamount') prefix-matches the intent
+ verb 'calls' — the #2507 pollution vector."""
+ G = nx.Graph()
+ G.add_node("svc", label="ChargeCustomerService", source_file="billing/charge.py")
+ G.add_node("c1", label="BillingJob", source_file="billing/job.py")
+ G.add_node("c2", label="CheckoutFlow", source_file="checkout/flow.py")
+ G.add_node("c3", label="RetryWorker", source_file="workers/retry.py")
+ for caller in ("c1", "c2", "c3"):
+ G.add_edge(caller, "svc", relation="calls", context="call")
+ G.add_node("decoy", label=".callStoreWithAmount()", source_file="store/amount.py")
+ return G
+
+
+def test_relational_verb_does_not_seat_decoy_seed():
+ """'Who calls X?' must seed on X, not on a decoy that merely prefix-matches
+ the intent verb 'calls' via its tokenized label (#2507). The gap window
+ already excludes the decoy; the per-term guarantee must not re-seat it."""
+ G = _make_callers_graph()
+ # Sanity-check the pollution premise: the decoy IS the singleton winner for
+ # 'calls', so pre-fix the guarantee loop would have seated it as a BFS root.
+ qs = _score_query(G, _query_terms("Who calls ChargeCustomerService?"), collect_per_term_seeds=True)
+ assert qs.best_seed_by_term.get("calls") == "decoy"
+
+ text = _query_graph_text(G, "Who calls ChargeCustomerService?", mode="bfs", depth=2)
+ header = text.splitlines()[0]
+ assert "ChargeCustomerService" in header.split("Start:")[1]
+ assert ".callStoreWithAmount()" not in header
+ for caller in ("BillingJob", "CheckoutFlow", "RetryWorker"):
+ assert caller in text
+
+
+def test_relational_verb_as_bare_query_still_seeds_symbol():
+ """All-intent fallback: a query that is ONLY intent words keeps the seed
+ guarantee, so a corpus-legit identifier literally named 'calls' stays
+ reachable via the bare query 'calls' (#2507, preserving #1597's intent)."""
+ G = nx.Graph()
+ G.add_node("calls_fn", label="calls", source_file="src/calls.py")
+ G.add_node("other", label="unrelated_helper", source_file="src/other.py")
+ text = _query_graph_text(G, "calls", mode="bfs", depth=1)
+ assert "No matching nodes found." not in text
+ assert "calls" in text.splitlines()[0].split("Start:")[1]
+
+
+def test_relational_verb_symbol_still_wins_seat_on_merit():
+ """Demotion only strips the GUARANTEE: a node literally named 'calls' whose
+ score sits within the gap window is still seeded alongside the other term's
+ node on a multi-term query (#2507)."""
+ G = nx.Graph()
+ G.add_node("calls_fn", label="calls", source_file="src/calls.py")
+ G.add_node("ext", label="extract", source_file="src/extract.py")
+ text = _query_graph_text(G, "calls extract", mode="bfs", depth=1)
+ start = text.splitlines()[0].split("Start:")[1]
+ assert "calls" in start
+ assert "extract" in start
+
+
+def test_uses_phrasing_does_not_seat_decoy_seed():
+ """'what uses X' must not seat a decoy that prefix-matches the intent verb
+ 'uses' (#2507). 'uses' is deliberately NOT a _CONTEXT_HINTS alias (its
+ relation is ambiguous); the demotion set alone handles it."""
+ G = nx.Graph()
+ G.add_node("svc", label="ChargeCustomerService", source_file="billing/charge.py")
+ G.add_node("c1", label="BillingJob", source_file="billing/job.py")
+ G.add_edge("c1", "svc", relation="uses", context="call")
+ G.add_node("decoy", label="usesDiscountCode()", source_file="promo/discount.py")
+ text = _query_graph_text(G, "what uses ChargeCustomerService?", mode="bfs", depth=2)
+ header = text.splitlines()[0]
+ assert "ChargeCustomerService" in header.split("Start:")[1]
+ assert "usesDiscountCode()" not in header
+
+
+def test_infer_context_filters_for_callers_question():
+ """'callers of X' phrasing infers the call context (#2507 companion)."""
+ assert _infer_context_filters("callers of ChargeCustomerService") == ["call"]
+
+
def test_query_graph_text_parameter_type_context_filter_changes_traversal():
import networkx as nx
from graphify.serve import _query_graph_text