fix(callflow,query): direction-aware callflow; drop relational-verb seed pollution; bump to 0.9.35

#2508 (thanks @Tomaskobel): callflow loads the graph directed and recovers
direction from _src/_tgt markers (consistent with the path fix), so
caller/callee columns are correct; indirect calls are now counted.
#2507 (thanks @filipechagas): relational-intent verbs (calls/uses/extends/
...) are 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
real symbol name can still be seeded on merit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
safishamsi
2026-08-06 17:29:18 +01:00
co-authored by Claude Opus 4.8
parent 2320f2adc1
commit 9f25a3aaa1
6 changed files with 324 additions and 11 deletions
+10 -1
View File
@@ -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.
+35 -7
View File
@@ -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 "<br>".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(
<th style="width:20%">{callee_header}</th>
<th style="width:20%">{desc_header}</th>
</tr>
{generate_call_table_rows(sec_nodes, sec_edges, lang)}
{generate_call_table_rows(sec_nodes, sec_edges, lang, edges, nodes)}
</table>
{generate_section_cards(sec, sec_nodes, sec_edges, lang)}
+38 -2
View File
@@ -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)
+1 -1
View File
@@ -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"
+160
View File
@@ -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")
+80
View File
@@ -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