fix: preserve calls edge direction in graphify query output#2080
Closed
Yyunozor wants to merge 1 commit into
Closed
fix: preserve calls edge direction in graphify query output#2080Yyunozor wants to merge 1 commit into
Yyunozor wants to merge 1 commit into
Conversation
`graphify query` builds an undirected nx.Graph (so BFS/DFS can explore both callers and callees of the seed node), but its text renderer assumed the BFS/DFS visit order (u, v) was always the edge's (source, target). On an undirected graph that assumption only holds when the seed happens to be the caller: seeding on the callee makes BFS/DFS visit the callee first, so a `caller --calls--> callee` edge was rendered backwards as `callee --calls--> caller`. graph.json's own source/target fields stay correct on disk; only the query rendering was wrong. `graphify path` and `graphify explain` don't have this problem because they force directed=True on load (Graphify-Labs#849, Graphify-Labs#853), and the MCP query_graph tool's _load_graph() does the same. Doing that for CLI `query` too was tried and reverted: forcing a DiGraph makes G.neighbors() return successors only, so a query seeded on a leaf/sink node (no outgoing edges) found zero neighbors instead of its callers — a recall regression, not just a display fix, and it would make the CLI and MCP query tools diverge in what they discover even though they'd render direction identically. Fix instead mirrors the _src/_tgt pattern graphify/build.py already uses for the same underlying problem (undirected storage loses direction): the CLI now stashes each link's true source/target on its edge data as _src/_tgt before constructing the (still undirected) graph, and _subgraph_to_text renders EDGE lines from _src/_tgt when present, falling back to (u, v) otherwise. Traversal itself is unchanged, so recall is unaffected — verified against the unpatched CLI, the node counts returned for the same seeds are identical before and after this fix, only the printed edge direction changes. Adds two regression tests in tests/test_query_cli.py seeding the same `calls` edge from both endpoints; the callee-seeded case fails on the prior code with the exact backwards-edge symptom above.
safishamsi
added a commit
that referenced
this pull request
Jul 21, 2026
safishamsi
added a commit
that referenced
this pull request
Jul 21, 2026
Yyunozor
force-pushed
the
fix/query-calls-edge-direction
branch
from
July 21, 2026 21:27
d976867 to
cde0c28
Compare
Contributor
Author
This was referenced Jul 21, 2026
wojiucece
added a commit
to wojiucece/graphify
that referenced
this pull request
Jul 22, 2026
上游 0.9.21-0.9.24 数据完整性 + 正确性修复批: - Graphify-Labs#2062 uninstall 不再误删用户 ### graphify(新增 _remove_marker_section) - Graphify-Labs#2072 src-layout import 解析 - Graphify-Labs#2074 path 不再伪造 calls 边 - Graphify-Labs#2080 query 保留 calls 边方向 - Graphify-Labs#2073 --no-label 不压制真实 label - Graphify-Labs#2068 ghost-merge 用完整 source_file - Graphify-Labs#2051 watch 不误删 remote 节点 - serve: get_neighbors/get_community 支持 token_budget 冲突:仅 pyproject.toml 版本号(0.9.20+fork.1 -> 0.9.24+fork.1) install.py 自动合并通过(Graphify-Labs#2062 改 uninstall 路径,fork 定制在 install 路径,区域不重叠) check-custom.sh 守护全通过
safishamsi
pushed a commit
that referenced
this pull request
Jul 22, 2026
…2082) `from pkg import mod as alias` correctly emits the file-level `imports_from` edge, but every downstream `alias.func()` call was dropped: the module arm of the cross-file member-call resolver (#1883, _resolve_python_member_calls in extract.py) matches a call receiver against the imported module's own file stem, with no awareness that the local binding in the importing file can be a different name. `from pkg import mod` / `mod.func()` resolves because the receiver ("mod") equals the stem; aliasing breaks the match because the receiver ("alias") never does, and the calls edge silently disappears while imports_from stays present -- the graph looks connected, only the symbol-level reverse query comes back empty. `import pkg.mod as alias` regresses the same way through the same resolver. Root cause is a missing propagation, not a missing feature: the local alias is already parsed correctly in two places (_python_imported_names in extractors/resolution.py, and the aliased_import branch of _import_python in extract.py) but discarded before it reaches the edges the module arm reads. Fix threads the alias through as a `local_alias` field on the `imports`/`imports_from` edge (mirroring the existing `target_file` transient-hint pattern, #1814, including its pop-once-consumed hygiene so the hint never reaches graph.json): - _import_python now splits the alias off `import pkg.mod as alias` and stamps it on the edge instead of only using it to compute the bare module_name. - _SymbolResolutionFacts.module_imports gains a 4th `local_name` slot so the `from pkg import submod [as alias]` submodule path (#1146) carries the binding through to _apply_symbol_resolution_facts, which now stamps `local_alias` on the edge whenever it differs from the submodule's own stem. - The module arm's receiver match now accepts the tracked alias in addition to the module's real stem, keyed per (importing file, target module) so two files aliasing the same module differently each match their own binding. - extract() pops `local_alias` off every edge right after run_language_resolvers runs, and build_from_json drops it from edge attrs too -- the same two spots target_file is dropped at (#1814), except the extract()-side pop has to happen AFTER the resolver reads the field, not at the earlier point _disambiguate_colliding_ node_ids already pops target_file: that function runs before run_language_resolvers, so popping local_alias there would strip it before the resolver ever sees it and silently undo the fix above. Known limitation, left out of scope: two different aliases bound to the same module in the same file only resolve the last one registered, since the match is one alias slot keyed per (importing file, target module) -- not a regression, since the parent resolved neither. Adds five regression tests in tests/test_extract.py: the issue's own shape (`from pkg import gate as m_gate`), its try/except-guarded variant (the issue's literal repro, confirming the drop is independent of try: nesting), the adjacent `import mathlib as m` and dotted `import pkg.gate as g_alias` forms, and the relative `from . import gate as r_gate` form -- plus an assertion that `local_alias` never survives into the returned edges. All fail on the parent commit with the exact missing-edge symptom and pass after the fix. Full suite: 3554 passed vs. 3549 on the unfixed parent, a delta of exactly these five new tests; ruff clean. #2080's calls-edge-direction regression tests (test_serve.py) still pass unchanged.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
graphify querycan print acallsedge backwards —callee --calls--> caller— depending on which endpoint the query term matches. The graph on disk is correct; only the query CLI's text output is wrong.Reproduction
graph.jsonhas the correctsource/targetfor this edge in both runs.Root cause
queryloads the graph as an undirectednx.Graph— deliberately, so BFS/DFS can reach both callers and callees of the seed — but_subgraph_to_textrenders each edge asEDGE {u} --> {v}in traversal visit order, which on an undirected graph isn't the stored(source, target). Seeding on the callee visits the callee first, so the edge prints backwards.Forcing
directed=Trueon load (whatpathandexplaindo) isn't the right fix here: on aDiGraph,G.neighbors()returns successors only, so a query seeded on a leaf/sink node finds zero neighbors instead of its callers — a recall regression. #829 fixed this bug class forpath,explainand the MCP tools, but thequeryCLI kept the backwards rendering.Fix
Same
_src/_tgtpatternbuild.pyalready uses when direction has to survive undirected storage:graphify/cli.py: stash each link's on-disksource/targeton its edge data as_src/_tgt.graphify/serve.py(_subgraph_to_text): renderEDGElines from_src/_tgtwhen present, falling back to(u, v).Traversal is unchanged (same node counts per seed before and after); no schema change;
path,explainand the MCP tools untouched.Tests / validation
Two regression tests in
tests/test_query_cli.pyseed the samecallsedge from both endpoints; the callee-seeded one fails on the prior code with the exact symptom above.Parent commit (
abff1b1), same environment: 3521 passed, 0 failed — the delta is exactly the 2 tests this PR adds.ruff checkclean.