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
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,26 @@

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

## Unreleased

- Fix: AST edges now retain portable token-level occurrence evidence. Python
annotations use their real line/column span and parameter name, repeated
evidence on one relation is aggregated deterministically, and duplicate
emissions introduced by post-resolution endpoint rewrites are suppressed.
Diagnostics separate legitimate source repetition from producer defects.
Deliberately unindexed data JSON is now a cached intentional skip rather than
a recurring zero-node warning, with failures and unsupported files reported
separately.
- 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
Loading