Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,18 @@

Full release notes with details on each version: [GitHub Releases](https://github.com/safishamsi/graphify/releases)

## Unreleased

- Feat: add opt-in `--multigraph` builds backed by a persistent directed
`MultiDiGraph`. Canonically distinct relations between the same endpoints
retain stable keys; exact duplicate occurrences aggregate through
`occurrence_count`. Incremental updates, watch/hook rebuilds, cluster-only,
no-cluster, graph merges, query/path/explain, HTML, wiki, Obsidian, GraphML,
graph databases, MCP, reports, and diagnostics preserve or explicitly reject
unsupported simplification. Community detection, cohesion, god nodes, and
centrality use an unweighted simple undirected topology projection so
multiplicity does not distort analysis. The default remains `Graph`.

## 0.9.26 (2026-07-25)

- Fix: `graphify query`/`explain` no longer fabricate `indirect_call` edges to class definitions (#2137, thanks @Rishet11). The callable guard admitted classes, so passing a class as a value (`select(Model)`, `db.get(Model, id)`, `except (ErrorA, ErrorB)`, `getattr(obj, "Name", 0)`) produced a false inferred call edge in both the intra-file and cross-file paths. Classes are now tracked separately and excluded from `indirect_call`; direct instantiation still emits its `calls` edge. The suppression is context-blind, so a genuine higher-order class callback that is actually invoked (e.g. `map(Point, coords)`) also loses its edge, which is the intended tradeoff.
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -734,6 +734,7 @@ graphify extract ./docs --api-timeout 900 # longer HTTP timeout for slow lo
graphify extract ./docs --google-workspace # export .gdoc/.gsheet/.gslides via gws before extraction
graphify extract ./src --no-gitignore # include git-ignored source; still honor .graphifyignore
graphify extract ./docs --mode deep # richer semantic extraction via extended system prompt
graphify extract ./src --multigraph # preserve directed parallel relations with stable edge keys
graphify extract ./docs --no-cluster # raw extraction only, skip clustering
graphify extract ./docs --timing # print per-stage wall-clock timings to stderr (also works on cluster-only)
graphify extract ./docs --force # overwrite graph.json even if new graph has fewer nodes (use after refactors or to clear ghost duplicates)
Expand Down
4 changes: 4 additions & 0 deletions graphify/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -521,13 +521,15 @@ def _run_cli() -> None:
print(" --max-examples N max same-endpoint examples to print (default 5)")
print(" --directed force directed post-build simulation")
print(" --undirected force undirected post-build simulation")
print(" --multigraph force MultiDiGraph post-build simulation")
print(" (default follows JSON directed flag;")
print(" raw extraction with no flag defaults directed)")
print(" --extract-path PATH extractor source for suppression scan")
print(" clone <github-url> clone a GitHub repo locally and print its path for /graphify")
print(" merge-driver <base> <current> <other> git merge driver: union-merge two graph.json files (set up via hook install)")
print(" merge-graphs <g1> <g2> merge two or more graph.json files into one cross-repo graph")
print(" --out <path> output path (default: graphify-out/merged-graph.json)")
print(" --multigraph preserve parallel relations in a directed multigraph")
print(" --branch <branch> checkout a specific branch (default: repo default)")
print(" --out <dir> clone to a custom directory (default: ~/.graphify/repos/<owner>/<repo>)")
print(" add <url> fetch a URL and save it to ./raw, then update the graph")
Expand All @@ -536,6 +538,7 @@ def _run_cli() -> None:
print(" --dir <path> target directory (default: ./raw)")
print(" watch <path> watch a folder and rebuild the graph on code changes")
print(" update <path> re-extract code files and update the graph (no LLM needed)")
print(" --multigraph preserve parallel directed relations (also preserves existing mode)")
print(" --force overwrite graph.json even if the rebuild has fewer nodes")
print(" (also: GRAPHIFY_FORCE=1 env var; use after refactors that delete code)")
print(" --no-cluster skip clustering, write raw extraction only")
Expand Down Expand Up @@ -609,6 +612,7 @@ def _run_cli() -> None:
print(" --api-timeout S per-request timeout in seconds for the LLM client (default: 600)")
print(" --out DIR, --output DIR output dir (default: <path>); writes <DIR>/graphify-out/")
print(" --google-workspace export .gdoc/.gsheet/.gslides shortcuts via gws before extraction")
print(" --multigraph preserve parallel directed relations in a MultiDiGraph")
print(" --no-gitignore ignore .gitignore and .git/info/exclude (prioritizes .graphifyignore)")
print(" --no-cluster skip clustering, write raw extraction only")
print(" --code-only index code (local AST, no API key) and skip doc/paper/image files")
Expand Down
10 changes: 6 additions & 4 deletions graphify/analyze.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from pathlib import Path
import networkx as nx

from graphify.build import edge_data
from graphify.build import analysis_projection, edge_data

# Builtin/mock names that can appear as annotation-derived nodes in pre-existing
# graphs. Excluded from god-node ranking so they don't displace real abstractions
Expand Down Expand Up @@ -104,7 +104,8 @@ def god_nodes(G: nx.Graph, top_n: int = 10) -> list[dict]:
File-level hub nodes are excluded: they accumulate import/contains edges
mechanically and don't represent meaningful architectural abstractions.
"""
degree = dict(G.degree())
topology = analysis_projection(G)
degree = {node: topology.degree(node) for node in G.nodes}
sorted_nodes = sorted(degree.items(), key=lambda x: x[1], reverse=True)
result = []
for node_id, deg in sorted_nodes:
Expand Down Expand Up @@ -281,7 +282,7 @@ def _cross_file_surprises(G: nx.Graph, communities: dict[int, list[str]], top_n:
Each result includes a 'why' field explaining what makes it non-obvious.
"""
node_community = _node_community_map(communities)
degrees = dict(G.degree())
degrees = dict(analysis_projection(G).degree())
candidates = []

for u, v, data in G.edges(data=True):
Expand Down Expand Up @@ -347,7 +348,8 @@ def _cross_community_surprises(
return []
if G.number_of_nodes() > 5000:
return []
betweenness = nx.edge_betweenness_centrality(G)
topology = analysis_projection(G)
betweenness = nx.edge_betweenness_centrality(topology)
top_edges = sorted(betweenness.items(), key=lambda x: x[1], reverse=True)[:top_n]
result = []
for (u, v), score in top_edges:
Expand Down
189 changes: 171 additions & 18 deletions graphify/build.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
# before any graph construction happens.
#
from __future__ import annotations
import hashlib
import json
import math
import os
Expand Down Expand Up @@ -292,6 +293,76 @@ def edge_datas(G: nx.Graph, u: str, v: str) -> list[dict]:
return [raw]


def _canonical_json_value(value: object) -> object:
"""Return a JSON-stable value for persistent edge identity."""
if isinstance(value, dict):
return {
str(key): _canonical_json_value(item)
for key, item in sorted(value.items(), key=lambda pair: str(pair[0]))
}
if isinstance(value, (list, tuple)):
return [_canonical_json_value(item) for item in value]
if isinstance(value, set):
return sorted(
(_canonical_json_value(item) for item in value),
key=lambda item: json.dumps(item, sort_keys=True, default=str),
)
if value is None or isinstance(value, (str, int, float, bool)):
return value
return str(value)


def canonical_edge_key(source: str, target: str, attrs: dict) -> str:
"""Stable key for one canonical directed relationship.

The key includes every persisted semantic attribute, while excluding
transport metadata and the duplicate counter. It is therefore stable across
extraction order and checkout paths once ``source_file`` has been
relativized, but changes when relation, context, location, confidence, or
another semantic attribute changes.
"""
semantic = {
key: value
for key, value in attrs.items()
if key not in {"key", "occurrence_count", "_src", "_tgt"}
}
payload = {
"source": source,
"target": target,
"attributes": _canonical_json_value(semantic),
}
encoded = json.dumps(
payload,
sort_keys=True,
separators=(",", ":"),
ensure_ascii=False,
default=str,
).encode("utf-8")
return "edge_" + hashlib.sha256(encoded).hexdigest()


def analysis_projection(G: nx.Graph) -> nx.Graph:
"""Return the simple undirected topology used for graph analysis.

Parallel relations and opposite directions intentionally contribute one
unweighted endpoint pair. The canonical graph remains untouched.
"""
projected = nx.Graph()
projected.add_nodes_from(
(node, dict(attrs))
for node, attrs in sorted(G.nodes(data=True), key=lambda row: str(row[0]))
)
pairs = {
(source, target) if str(source) <= str(target) else (target, source)
for source, target in G.edges()
}
projected.add_edges_from(
(source, target, {"weight": 1.0})
for source, target in sorted(pairs, key=lambda pair: (str(pair[0]), str(pair[1])))
)
return projected


def dedupe_nodes(nodes: list[dict]) -> list[dict]:
"""Collapse nodes sharing an ``id``, last-writer-wins on attributes.

Expand Down Expand Up @@ -487,14 +558,26 @@ def _doc_twin_remap(nodes: list) -> dict[str, str]:
return remap


def build_from_json(extraction: dict, *, directed: bool = False, root: str | Path | None = None) -> nx.Graph:
def build_from_json(
extraction: dict,
*,
directed: bool = False,
multigraph: bool = False,
root: str | Path | None = None,
) -> nx.Graph:
"""Build a NetworkX graph from an extraction dict.

multigraph=True produces a MultiDiGraph and implies directed=True.
directed=True produces a DiGraph that preserves edge direction (source→target).
directed=False (default) produces an undirected Graph for backward compatibility.
root: if given, absolute source_file paths from semantic subagents are made
relative to root so all nodes share a consistent path key (#932).
"""
if multigraph:
from graphify.multigraph_compat import require_multigraph_capabilities

require_multigraph_capabilities()
directed = True
_root = str(Path(root).resolve()) if root else None
# NetworkX <= 3.1 serialised edges as "links"; remap to "edges" for compatibility.
if "edges" not in extraction and "links" in extraction:
Expand Down Expand Up @@ -594,7 +677,13 @@ def build_from_json(extraction: dict, *, directed: bool = False, root: str | Pat
if isinstance(he, dict) and isinstance(he.get("nodes"), list):
he["nodes"] = [_doc_remap.get(n, n) for n in he["nodes"]]

G: nx.Graph = nx.DiGraph() if directed else nx.Graph()
G: nx.Graph
if multigraph:
G = nx.MultiDiGraph()
elif directed:
G = nx.DiGraph()
else:
G = nx.Graph()
for node in extraction.get("nodes", []):
# Skip dict nodes with a missing or non-hashable id (e.g. a list emitted
# by a buggy LLM extraction) so NetworkX add_node never raises
Expand Down Expand Up @@ -761,14 +850,19 @@ def build_from_json(extraction: dict, *, directed: bool = False, root: str | Pat
# direction in _src/_tgt; when two edges collapse onto the same node pair the
# last write wins, so an unstable iteration order flips _src/_tgt run-to-run
# and makes the serialized graph churn. Sorting fixes the last-write outcome.
for edge in sorted(
extraction.get("edges", []),
key=lambda e: (
str(e.get("source", e.get("from", ""))),
str(e.get("target", e.get("to", ""))),
str(e.get("relation", "")),
),
):
def _edge_sort_key(edge: dict) -> tuple:
base = (
str(edge.get("source", edge.get("from", ""))),
str(edge.get("target", edge.get("to", ""))),
str(edge.get("relation", "")),
)
if not multigraph:
return base
return base + (
json.dumps(edge, sort_keys=True, ensure_ascii=False, default=str),
)

for edge in sorted(extraction.get("edges", []), key=_edge_sort_key):
if "source" not in edge and "from" in edge:
edge["source"] = edge["from"]
if "target" not in edge and "to" in edge:
Expand Down Expand Up @@ -816,7 +910,11 @@ def build_from_json(extraction: dict, *, directed: bool = False, root: str | Pat
# strings, NaN/inf, negatives — while numeric strings coerce cleanly.
# Repair (not drop) the key so graph.json round-trips a clean value and a
# cluster-only/--update reload never re-ingests the null.
attrs = {k: v for k, v in edge.items() if k not in ("source", "target", "target_file", "local_alias")}
attrs = {
k: v
for k, v in edge.items()
if k not in ("source", "target", "target_file", "local_alias", "key")
}
for _num_key in ("weight", "confidence_score"):
if _num_key in attrs:
try:
Expand Down Expand Up @@ -889,7 +987,23 @@ def build_from_json(extraction: dict, *, directed: bool = False, root: str | Pat
existing.get("_src") == tgt and existing.get("_tgt") == src
):
continue
G.add_edge(src, tgt, **attrs)
if multigraph:
edge_key = canonical_edge_key(str(src), str(tgt), attrs)
incoming_count = attrs.pop("occurrence_count", 1)
try:
incoming_count = max(1, int(incoming_count))
except (TypeError, ValueError):
incoming_count = 1
if G.has_edge(src, tgt, edge_key):
existing = G[src][tgt][edge_key]
existing["occurrence_count"] = (
int(existing.get("occurrence_count", 1)) + incoming_count
)
else:
attrs["occurrence_count"] = incoming_count
G.add_edge(src, tgt, key=edge_key, **attrs)
else:
G.add_edge(src, tgt, **attrs)
hyperedges = extraction.get("hyperedges", [])
if hyperedges:
# Relativize hyperedge source_file the same way nodes and edges are
Expand Down Expand Up @@ -944,12 +1058,14 @@ def build(
extractions: list[dict],
*,
directed: bool = False,
multigraph: bool = False,
dedup: bool = True,
dedup_llm_backend: str | None = None,
root: str | Path | None = None,
) -> nx.Graph:
"""Merge multiple extraction results into one graph.

multigraph=True produces a MultiDiGraph and implies directed=True.
directed=True produces a DiGraph that preserves edge direction (source→target).
directed=False (default) produces an undirected Graph for backward compatibility.
dedup=True (default) runs entity deduplication before building the graph.
Expand All @@ -976,7 +1092,12 @@ def build(
combined["nodes"], combined["edges"], communities={},
dedup_llm_backend=dedup_llm_backend,
)
return build_from_json(combined, directed=directed, root=root)
return build_from_json(
combined,
directed=directed,
multigraph=multigraph,
root=root,
)


def _norm_label(label: str | None) -> str:
Expand Down Expand Up @@ -1047,6 +1168,7 @@ def build_merge(
prune_sources: list[str] | None = None,
*,
directed: bool = False,
multigraph: bool | None = None,
dedup: bool = True,
dedup_llm_backend: str | None = None,
root: str | Path | None = None,
Expand Down Expand Up @@ -1083,11 +1205,28 @@ def build_merge(
existing_edges = list(data.get(links_key, []))
existing_hyperedges = list(data.get("hyperedges", []))
had_graph = True
existing_multigraph = data.get("multigraph") is True
existing_directed = data.get("directed") is True
else:
existing_nodes = []
existing_edges = []
existing_hyperedges = []
had_graph = False
existing_multigraph = False
existing_directed = False

if existing_multigraph:
if multigraph is False:
raise ValueError(
"graphify: refusing to simplify an existing MultiDiGraph; "
"re-run with --multigraph or omit the explicit downgrade."
)
multigraph = True
directed = True
elif multigraph is None:
multigraph = False
if existing_directed:
directed = True

# Effective root for relativizing absolute source_file / prune paths back to the
# stored relative source_file keys. When the caller passes root we use it;
Expand Down Expand Up @@ -1131,7 +1270,14 @@ def _kept(item: dict) -> bool:
base = [{"nodes": existing_nodes, "edges": existing_edges}] if had_graph else []

all_chunks = base + list(new_chunks)
G = build(all_chunks, directed=directed, dedup=dedup, dedup_llm_backend=dedup_llm_backend, root=root)
G = build(
all_chunks,
directed=directed,
multigraph=multigraph,
dedup=dedup,
dedup_llm_backend=dedup_llm_backend,
root=root,
)

# Prune set for deleted source files — both the raw form (matches nodes that
# kept absolute source_file) and the normalised relative form (matches nodes
Expand Down Expand Up @@ -1216,10 +1362,17 @@ def _prune_match(sf: "str | None") -> bool:
file=sys.stderr,
)

edges_to_remove = [
(u, v) for u, v, d in G.edges(data=True)
if _prune_match(d.get("source_file"))
]
if G.is_multigraph():
edges_to_remove = [
(u, v, key)
for u, v, key, d in G.edges(keys=True, data=True)
if _prune_match(d.get("source_file"))
]
else:
edges_to_remove = [
(u, v) for u, v, d in G.edges(data=True)
if _prune_match(d.get("source_file"))
]
if edges_to_remove:
G.remove_edges_from(edges_to_remove)
print(
Expand Down
Loading