Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
23be870
feat: FalkorDB backend (server + embedded Lite), replacing NetworkX
galshubeli Jun 17, 2026
6e689de
refactor: rename graph-connection helpers to reflect they connect, no…
galshubeli Jun 17, 2026
b6e54ff
review: FalkorDB-only consistency + lazy UDF load
galshubeli Jun 17, 2026
905131f
review: bound two O(n^2)/whole-graph costs on large graphs
galshubeli Jun 17, 2026
a8e9620
review: guard affected.connect_graph + add exporter/error-path tests
galshubeli Jun 17, 2026
76c3d38
ci+perf: FalkorDB CI service container; one-pass degree map in cluster()
galshubeli Jun 17, 2026
bee3979
Merge origin/v8 into falkordb-backend
galshubeli Jun 17, 2026
abd558d
review: address PR #1 Copilot comments
galshubeli Jun 17, 2026
cb702d7
fix: _find_node label_tokens used undefined `d` (merge artifact)
galshubeli Jun 17, 2026
9c6016b
fix: preserve _origin so incremental rebuild evicts removed symbols (…
galshubeli Jun 17, 2026
cfc80b3
ci: regenerate uv.lock for the FalkorDB dependency set
galshubeli Jun 17, 2026
fcad53d
fix: GraphStore.in_degree/out_degree + build_merge test uses graph_name
galshubeli Jun 17, 2026
23dc4ac
Merge origin/v8 (18 commits) into falkordb-backend
galshubeli Jun 18, 2026
ad18f03
fix: deterministic _find_node tie-ordering across backends
galshubeli Jun 18, 2026
f51f720
fix: address PR #1379 review findings (build crash, packaging, edge d…
galshubeli Jun 18, 2026
4a0e961
fix: query traversal dropped same-level multi-parent edges
galshubeli Jun 18, 2026
779fc42
fix: keep pre-FalkorDB and --no-cluster graphs queryable (auto-import…
galshubeli Jun 18, 2026
b30e3ef
feat: zero-setup default — auto-fall back to embedded FalkorDB Lite
galshubeli Jun 18, 2026
459e233
Merge v8 into falkordb-backend: re-port FalkorDB onto the new cli.py
galshubeli Jul 28, 2026
1c229de
fix: port the remaining v8 tests onto the FalkorDB backend
galshubeli Jul 29, 2026
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
16 changes: 16 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,22 @@ jobs:
matrix:
python-version: ["3.10", "3.12"]

# FalkorDB is the graph backend; without a reachable instance the conftest
# session fixture skips every graph-dependent test, so CI would go green
# without exercising the engine at all. Run it as a service container — the
# fixtures connect to localhost:6379 by default (overridable via FALKORDB_*).
services:
falkordb:
image: falkordb/falkordb:latest
ports:
- 6379:6379
# falkordb is redis-based, so redis-cli ping gates readiness before tests.
options: >-
--health-cmd "redis-cli ping"
--health-interval 5s
--health-timeout 3s
--health-retries 10

steps:
- uses: actions/checkout@v6
with:
Expand Down
155 changes: 119 additions & 36 deletions graphify/affected.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,6 @@
from typing import Iterable
import unicodedata

import networkx as nx


DEFAULT_AFFECTED_RELATIONS = (
"calls",
Expand Down Expand Up @@ -50,16 +48,16 @@ def _format_location(data: dict) -> str:
return str(source_file)


def _normalize_label(label: str) -> str:
return unicodedata.normalize("NFC", label).casefold()


def _bare_name(label: str) -> str:
"""Lowercased label with the callable decoration (trailing "()") removed."""
"""Normalized label with the callable decoration (trailing "()") removed."""
label = _normalize_label(label)
return label[:-2] if label.endswith("()") else label


def _normalize_label(label: str) -> str:
return unicodedata.normalize("NFC", label).casefold()


def _prefer_file_node(
graph: nx.Graph,
node_ids: list[str],
Expand Down Expand Up @@ -95,11 +93,37 @@ def _prefer_file_node(
return None


def resolve_seed(graph: nx.Graph, query: str) -> str | None:
def resolve_seed(graph, query: str) -> str | None:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regressionresolve_seed()

11 callers depend on it (afferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regressionresolve_seed()

11 callers depend on it (afferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

# A trailing path separator must not change a source-file match — serve's
# _find_node tokenizes the path (which drops it), so strip it here for parity
# (otherwise `affected "src/x.ts/"` returned None while `explain` resolved it).
query = query.rstrip("/\\") or query
# FalkorDB-native: resolve via scoped queries (no full-graph load).
if hasattr(graph, "find_node_ids"):
if graph.has_node(query):
return query
# Mirror the in-memory tier order: exact label -> bare callable name ->
# exact source_file -> substring (#1353). Without the bare-name tier a
# query like "foo" failed to resolve to "foo()" on the native path.
for kwargs in ({"label": query}, {"label_bare": _bare_name(query)}):
matches = graph.find_node_ids(limit=2, **kwargs)
if len(matches) == 1:
return str(matches[0])
# source_file tier: when several nodes share the file, prefer the
# file-level node, exactly as the in-memory path does — otherwise a
# path query is ambiguous natively but resolves in memory.
src_matches = [str(m) for m in graph.find_node_ids(source_file=query, limit=50)]
if len(src_matches) == 1:
return src_matches[0]
if src_matches:
preferred = _prefer_file_node(graph, src_matches, query)
if preferred is not None:
return preferred
matches = graph.find_node_ids(limit=2, label_contains=query)
if len(matches) == 1:
return str(matches[0])
return None
# In-memory fallback (nx / MemGraph) — normalized + bare-name matching (#1353).
if query in graph:
return query
query_lower = _normalize_label(query)
Expand Down Expand Up @@ -150,6 +174,47 @@ def affected_nodes(
depth: int = 2,
) -> list[AffectedHit]:
relation_set = set(relations)

# FalkorDB-native: one batched reverse-edge query per BFS level (depth queries
# total) instead of a per-node loop over an in-memory copy of the graph.
if hasattr(graph, "incoming_edges"):
seen = {seed}
hits: list[AffectedHit] = []
frontier = [seed]
# #1669: seed the reverse walk with the root's own member nodes (one
# outward `method`/`contains` hop), mirroring the in-memory path. A caller
# can bind to a class's method node rather than the class node itself, so
# those callers are otherwise unreachable. Seeds only — not reported as
# hits, and `method`/`contains` stay out of the relation-filtered walk.
if hasattr(graph, "member_nodes"):
for member in sorted(str(m) for m in graph.member_nodes(seed)):
if member not in seen:
seen.add(member)
frontier.append(member)
for d in range(depth):
if not frontier:
break
rows = graph.incoming_edges(frontier, relation_set)
nxt: list[str] = []
for _fid, src, relation, via_file, via_loc in sorted(
rows, key=lambda r: (str(r[0]), str(r[1]), str(r[2]))
):
src = str(src)
if src in seen:
continue
seen.add(src)
# Location comes from the SAME edge whose relation passed the
# filter, so relation and site stay consistent (#BUG1).
hits.append(AffectedHit(
src, d + 1, str(relation),
via_file=str(via_file or "") or None,
via_location=str(via_loc or "") or None,
))
nxt.append(src)
frontier = nxt
return hits

# In-memory fallback (nx / MemGraph)
seen = {seed}
queue: deque[tuple[str, int]] = deque([(seed, 0)])
hits: list[AffectedHit] = []
Expand Down Expand Up @@ -206,7 +271,6 @@ def affected_nodes(
)
hits.append(hit)
queue.append((source, current_depth + 1))

return hits


Expand All @@ -223,8 +287,19 @@ def format_affected(
return f"No unique node match for {query}"

hits = affected_nodes(graph, seed, relations=relation_list, depth=depth)

# Fetch attrs for seed + all hits in one batched query (native) or per-node (fallback).
ids = [seed] + [h.node_id for h in hits]
if hasattr(graph, "node_attrs_batch"):
attrs = graph.node_attrs_batch(ids)
else:
attrs = {nid: dict(graph.nodes[nid]) for nid in ids if nid in graph}

def _label(nid):
return str(attrs.get(nid, {}).get("label") or nid)

lines = [
f"Affected nodes for {_node_label(graph, seed)}",
f"Affected nodes for {_label(seed)}",
f"Relations: {', '.join(relation_list)}",
f"Depth: {depth}",
]
Expand All @@ -233,40 +308,48 @@ def format_affected(
return "\n".join(lines)

for hit in hits:
data = graph.nodes[hit.node_id]
data = attrs.get(hit.node_id, {})
if hit.via_location:
# The relation SITE in this node's file (call/import/reference line),
# labeled by [via_relation] so it's never mistaken for a def line.
location = f"{hit.via_file or data.get('source_file') or '-'}:{hit.via_location}"
else:
location = _format_location(data) # honest fallback: the node's own def line
lines.append(
f"- {_node_label(graph, hit.node_id)} [{hit.via_relation}] {location}"
f"- {_label(hit.node_id)} [{hit.via_relation}] {location}"
)
return "\n".join(lines)


def load_graph(path: Path) -> nx.Graph:
import json
from networkx.readwrite import json_graph

try:
raw = json.loads(path.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError) as exc:
raise RuntimeError(
f"Cannot read graph file {path}: {exc}. "
"Re-run 'graphify extract' to regenerate it."
) from exc
# Force directed so stored caller→callee direction survives the round-trip;
# mirrors serve.py and __main__.py (#1174).
raw = {**raw, "directed": True}
# Normalize the edge key: graphify's `extract` output uses "edges" while
# networkx's node_link_data default is "links". Without this, an edges-keyed
# graph.json raises an uncaught KeyError: 'links' here — every other loader
# (__main__.py) already normalizes this (#738; same class as #1198).
if "links" not in raw and "edges" in raw:
raw = dict(raw, links=raw["edges"])
try:
return json_graph.node_link_graph(raw, edges="links")
except TypeError:
return json_graph.node_link_graph(raw)
def connect_graph(path: Path):
"""Open a connection to the FalkorDB-backed graph for the output dir containing
`path`. Cache-free: returns a store handle (a connection), it does NOT load the
graph into memory.

`path` is the legacy graph.json location (e.g. graphify-out/graph.json); we
use its parent directory to locate the FalkorDB pointer and open the store.
"""
from .store import open_store

resolved = Path(path)
out_dir = resolved.parent if resolved.suffix else resolved
store = open_store(out_dir, create=False)
if store.number_of_nodes() == 0:
# Back-compat: the store is empty but a node-link graph.json may still
# hold the graph (a pre-FalkorDB project, or a `--no-cluster` run that
# only wrote JSON). Import it on first use, exactly as serve._connect_graph
# does, so `affected`/`god-nodes` stay usable on the same graphs `query`
# and `explain` can already read.
from .serve import _import_graph_json_into_store

gj = resolved if resolved.suffix == ".json" else (out_dir / "graph.json")
_import_graph_json_into_store(gj, store)
# open_store(create=False) hands back a store even when no graph was built
# (it derives a name from the pointer/root rather than erroring), so guard the
# empty case here — matching serve._connect_graph — so `graphify affected`
# tells the user to build instead of silently reporting nothing.
if store.number_of_nodes() == 0:
raise FileNotFoundError(
f"No graph found for {out_dir} (FalkorDB graph empty). Re-run /graphify to build."
)
return store
Loading
Loading