Skip to content

fix: preserve calls edge direction in graphify query output#2080

Closed
Yyunozor wants to merge 1 commit into
Graphify-Labs:v8from
Yyunozor:fix/query-calls-edge-direction
Closed

fix: preserve calls edge direction in graphify query output#2080
Yyunozor wants to merge 1 commit into
Graphify-Labs:v8from
Yyunozor:fix/query-calls-edge-direction

Conversation

@Yyunozor

@Yyunozor Yyunozor commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

Summary

graphify query can print a calls edge 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

alpha.py:
    from beta import process_payload
    def run_pipeline(payload):
        return process_payload(payload)   # alpha calls beta

beta.py:
    def process_payload(payload):
        return {"status": "ok", "payload": payload}
$ graphify extract . --code-only
$ graphify query "run_pipeline"      # seeded on the CALLER
EDGE run_pipeline() --calls [EXTRACTED context=call]--> process_payload()   # correct

$ graphify query "process_payload"   # seeded on the CALLEE
EDGE process_payload() --calls [EXTRACTED context=call]--> run_pipeline()   # backwards

graph.json has the correct source/target for this edge in both runs.

Root cause

query loads the graph as an undirected nx.Graph — deliberately, so BFS/DFS can reach both callers and callees of the seed — but _subgraph_to_text renders each edge as EDGE {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=True on load (what path and explain do) isn't the right fix here: on a DiGraph, 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 for path, explain and the MCP tools, but the query CLI kept the backwards rendering.

Fix

Same _src/_tgt pattern build.py already uses when direction has to survive undirected storage:

  • graphify/cli.py: stash each link's on-disk source/target on its edge data as _src/_tgt.
  • graphify/serve.py (_subgraph_to_text): render EDGE lines from _src/_tgt when present, falling back to (u, v).

Traversal is unchanged (same node counts per seed before and after); no schema change; path, explain and the MCP tools untouched.

Tests / validation

Two regression tests in tests/test_query_cli.py seed the same calls edge from both endpoints; the callee-seeded one fails on the prior code with the exact symptom above.

uv run pytest tests/ -q
3523 passed, 3 skipped, 0 failed

Parent commit (abff1b1), same environment: 3521 passed, 0 failed — the delta is exactly the 2 tests this PR adds. ruff check clean.

`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.
@Yyunozor

Copy link
Copy Markdown
Contributor Author

Landed directly on v8 as 4ace951, with the extra guard and tests in 53d0f88 — closing this as superseded. Thanks for the quick review and the hardening!

@Yyunozor Yyunozor closed this 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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant