feat: Claude Code skill, Obsidian vault, install, tests

skill.md with full pipeline steps, Obsidian as default output (canvas, tags,
dataview, graph colors), two-command install, 71 tests, .gitignore, deps
This commit is contained in:
Safi
2026-04-04 18:53:43 +01:00
parent 77966a7622
commit ce47198be1
36 changed files with 3341 additions and 822 deletions
+1
View File
@@ -7,3 +7,4 @@ dist/
build/
.pytest_cache/
*.so
.graphify/
+113 -32
View File
@@ -1,65 +1,146 @@
# graphify
Any input → knowledge graph → clustered communities → interactive HTML + GraphRAG-ready JSON + audit report.
A Claude Code skill that turns any folder of files into a navigable knowledge graph — then opens it as an Obsidian vault you can explore, filter, and query.
```
┌──────────────────┐ ┌────────────────────────────────────────┐
│ │ │ .graphify/ │
│ /graphify ./raw │ ───▶ │ ├── GRAPH_REPORT.md # primary │
│ │ │ ├── graph.html # interactive │
│ │ │ └── graph.json # GraphRAG-ready│
└──────────────────┘ └────────────────────────────────────────┘
/graphify ./raw
```
## Why this exists
```
.graphify/
├── obsidian/ open as Obsidian vault to explore the graph visually
├── GRAPH_REPORT.md what the graph found — surprising connections, knowledge gaps, suggested questions
└── graph.json persistent graph — query it weeks later without re-reading anything
```
Every other graph tool handles codebases only, builds edges silently (you can't tell what was extracted vs invented), and gives you a graph with no explanation of what it means.
## The problem it solves
graphify handles any input, tags every edge `[EXTRACTED]`, `[INFERRED]`, or `[AMBIGUOUS]`, scores cluster quality as a plain number (not an emoji), and tells you when your corpus is small enough that you don't need a graph at all.
Andrej Karpathy described it well: he keeps a `/raw` folder where he drops papers, tweets, screenshots, and notes. The problem is that folder becomes opaque. You forget what's in it. You can't see what connects.
Claude can read any single file. But ask Claude "what connects paper A to the code in repo B?" and it will hallucinate — it hasn't read both, and even if it has, it has no memory of the connection next session.
graphify solves this by:
1. Reading everything once, extracting a persistent graph
2. Tagging every edge as `[EXTRACTED]` (explicitly stated), `[INFERRED]` (reasonable), or `[AMBIGUOUS]` (flagged for review) — you always know what was found vs invented
3. Running community detection to find clusters you didn't know existed
4. Surfacing cross-community connections — the things you would never think to ask about directly
5. Storing the graph in `.graphify/graph.json` so you can query it in any future session without re-extracting
## Install
Copy the skill into your Claude Code skills directory:
```bash
npx skills add safishamsi/graphify/skills/graphify
mkdir -p ~/.claude/skills/graphify
curl -s https://raw.githubusercontent.com/safishamsi/graphify/v1/skills/graphify/skill.md \
> ~/.claude/skills/graphify/SKILL.md
```
Add to `~/.claude/CLAUDE.md`:
```
- **graphify** (`~/.claude/skills/graphify/SKILL.md`) — any input to knowledge graph. Trigger: `/graphify`
```
## Usage
```bash
/graphify ./raw # full pipeline
/graphify ./my-repo --mode deep # thorough extraction
/graphify ./docs --no-viz # skip HTML
/graphify ./raw --neo4j # also export Cypher for Neo4j
/graphify query "what connects auth to the database?"
/graphify # run on current directory
/graphify ./raw # run on a specific folder
/graphify ./raw --mode deep # more aggressive INFERRED edge extraction
/graphify ./raw --update # re-extract only changed files, merge into existing graph
/graphify ./raw --watch # notify when new files appear (drop files, get pinged)
/graphify add https://arxiv.org/abs/1706.03762 # fetch a paper, save, update graph
/graphify add https://x.com/karpathy/status/... # fetch a tweet
/graphify add <url> --author "Karpathy" --contributor "safi" # tag who wrote it and who added it
/graphify query "what connects attention to the optimizer?" # BFS — broad context
/graphify query "how does the encoder reach the loss?" --dfs # DFS — trace a path
/graphify query "..." --budget 1500 # cap at N tokens
/graphify ./raw --html # also export graph.html (browser, no Obsidian needed)
/graphify ./raw --svg # also export graph.svg (embeds in Notion, GitHub)
/graphify ./raw --neo4j # generate cypher.txt for Neo4j import
```
Works with any mix of file types:
- `.py / .ts / .js / .go` etc → code (AST + semantic)
- `.md / .txt / .rst` → documents
- `.pdf` → papers (with citation mining)
Works with any mix of file types in the same folder:
| Type | Extensions | How it's extracted |
|------|-----------|-------------------|
| Code | `.py .ts .js .go .rs .java .cpp .rb` etc | AST (deterministic) + semantic (Claude) |
| Documents | `.md .txt .rst` | Claude reads and extracts concepts + relationships |
| Papers | `.pdf` | Citation mining + concept extraction |
| Images | `.png .jpg .webp .gif .svg` | Claude vision — reads UI screenshots, charts, tweets, diagrams, whiteboards |
## What you get
After running, Claude pastes three things directly into the chat:
**God nodes** — the highest-degree concepts (what everything connects through)
**Surprising connections** — cross-community edges; relationships between concepts that live in different clusters. These are what you didn't know to look for.
**Suggested questions** — 4-5 questions the graph is uniquely positioned to answer, with the reason why (which bridge node makes it interesting, which community boundary it crosses)
The full `GRAPH_REPORT.md` also includes community summaries with cohesion scores and a list of ambiguous edges for your review.
## Use cases
**New codebase** — run `/graphify` before touching anything. Find the god nodes (what you have to understand first), the community structure (what the major subsystems are), and the surprising connections (what talks to what that you wouldn't expect).
**Research reading list** — drop papers, tweets, and notes into `/raw`. Run `/graphify ./raw`. Get a graph of how concepts connect across everything you've read. Query it: "what connects sparse autoencoders to superposition?"
**Personal knowledge base** — leave `--watch` running on your `/raw` folder. Drop things in throughout the day. The graph grows. Query it weeks later without re-reading anything.
**Collaborative corpus** — use `--contributor` to tag who added what. The graph knows provenance. "What did safi add that connects to the attention mechanism?"
## What it will NOT do
- Won't invent edges — `[AMBIGUOUS]` exists so uncertain relationships are flagged, not hidden
- Won't claim the graph is useful when it isn't — corpus under 50K words gets a warning
- Won't re-extract unchanged files — `--update` uses a manifest to skip unchanged files
- Won't visualize graphs over 5,000 nodes — use `--no-viz` or query instead
## Files
```
.graphify/
├── GRAPH_REPORT.md # Corpus check · God nodes · Surprising connections ·
│ # Community summaries with cohesion scores · Ambiguous edges
├── graph.html # Interactive pyvis — color by community, hover for edge type
── graph.json # NetworkX node-link format, compatible with MS GraphRAG
graphify/
├── detect.py detect file types, auto-exclude venvs/caches/node_modules
├── extract.py parse files into nodes + edges (tree-sitter AST + Claude)
├── build.py assemble NetworkX graph from extraction JSON
── cluster.py Leiden community detection, cohesion scoring
├── analyze.py god nodes, bridge nodes, surprising connections, suggested questions
├── report.py render GRAPH_REPORT.md
├── export.py Obsidian vault, graph.json, graph.html, graph.svg, Neo4j Cypher
├── ingest.py fetch URLs (arXiv, Twitter/X, PDF, any webpage), save annotated markdown
├── validate.py JSON schema checks on extraction output
├── serve.py MCP stdio server — exposes graph tools to other agents
└── watch.py fs watcher, writes flag file when new files appear
skills/graphify/
└── skill.md the Claude Code skill — everything the agent runs
tests/ 71 tests, one file per module
pyproject.toml deps: networkx, graspologic, tree-sitter, pyvis
```
## What this will NOT do
## Tech stack
- Won't guarantee extraction correctness — `[AMBIGUOUS]` edges are yours to review
- Won't claim the graph is useful when it isn't — corpus < 50K words gets a warning
- Won't connect to external services unless you pass `--neo4j`
- Won't visualize graphs > 5,000 nodes — use `--no-viz` at that scale
| Layer | Library | Why |
|-------|---------|-----|
| Graph | NetworkX | Pure Python, same internals as MS GraphRAG |
| Community detection | Leiden via graspologic | Better than K-means for sparse graphs |
| Code parsing | tree-sitter | Multi-language AST, deterministic, zero hallucination |
| Extraction | Claude (parallel subagents) | Reads anything, outputs structured graph data |
| Visualization | Obsidian vault | Native graph view, wikilinks, search, no server needed |
No Neo4j required. No dashboards. No server. Runs entirely locally.
## Design principles
Informed by Karpathy's /raw folder workflow and his observation that most RAG infrastructure is overkill. The graph earns its complexity.
1. Extraction quality is everything — clustering is downstream of it
2. Show the numbers — cohesion is 0.91, not "good"
3. The best output is what you didn't know — Surprising Connections is not optional
4. Token cost is always visible
5. The graph earns its complexity — corpus under 50K words gets a warning to just use Claude directly
+7
View File
@@ -0,0 +1,7 @@
"""graphify — extract · build · cluster · analyze · report."""
from graphify.extract import extract, collect_files
from graphify.build import build_from_json
from graphify.cluster import cluster, score_all, cohesion_score
from graphify.analyze import god_nodes, surprising_connections, suggest_questions
from graphify.report import generate
from graphify.export import to_json, to_html, to_svg
+417
View File
@@ -0,0 +1,417 @@
"""Graph analysis: god nodes (most connected), surprising connections (cross-community), suggested questions."""
from __future__ import annotations
import networkx as nx
def _is_file_node(G: nx.Graph, node_id: str) -> bool:
"""
Return True if this node is a file-level hub node (e.g. 'client', 'models')
or an AST method stub (e.g. '.auth_flow()', '.__init__()').
These are synthetic nodes created by the AST extractor and should be excluded
from god nodes, surprising connections, and knowledge gap reporting.
"""
label = G.nodes[node_id].get("label", "")
if not label:
return False
# File-level hub: label is a filename with a code extension
if label.split(".")[-1] in ("py", "ts", "js", "go", "rs", "java", "rb", "cpp", "c", "h"):
return True
# Method stub: AST extractor labels methods as '.method_name()'
if label.startswith(".") and label.endswith("()"):
return True
# Module-level function stub: labeled 'function_name()' — only has a contains edge
# These are real functions but structurally isolated by definition; not a gap worth flagging
if label.endswith("()") and G.degree(node_id) <= 1:
return True
return False
def god_nodes(G: nx.Graph, top_n: int = 10) -> list[dict]:
"""Return the top_n most-connected real entities — the core abstractions.
File-level hub nodes are excluded: they accumulate import/contains edges
mechanically and don't represent meaningful architectural abstractions.
"""
degree = dict(G.degree())
sorted_nodes = sorted(degree.items(), key=lambda x: x[1], reverse=True)
result = []
for node_id, deg in sorted_nodes:
if _is_file_node(G, node_id) or _is_concept_node(G, node_id):
continue
result.append({
"id": node_id,
"label": G.nodes[node_id].get("label", node_id),
"edges": deg,
})
if len(result) >= top_n:
break
return result
def surprising_connections(
G: nx.Graph,
communities: dict[int, list[str]] | None = None,
top_n: int = 5,
) -> list[dict]:
"""
Find connections that are genuinely surprising — not obvious from file structure.
Strategy:
- Multi-file corpora: cross-file edges between real entities (not concept nodes).
Sorted AMBIGUOUS → INFERRED → EXTRACTED.
- Single-file / single-source corpora: cross-community edges that bridge
distant parts of the graph (betweenness centrality on edges).
These reveal non-obvious structural couplings.
Concept nodes (empty source_file, or injected semantic annotations) are excluded
from surprising connections because they are intentional, not discovered.
"""
# Identify unique source files (ignore empty/null source_file)
source_files = {
data.get("source_file", "")
for _, data in G.nodes(data=True)
if data.get("source_file", "")
}
is_multi_source = len(source_files) > 1
if is_multi_source:
return _cross_file_surprises(G, communities or {}, top_n)
else:
return _cross_community_surprises(G, communities or {}, top_n)
def _is_concept_node(G: nx.Graph, node_id: str) -> bool:
"""
Return True if this node is a manually-injected semantic concept node
rather than a real entity found in source code.
Signals:
- Empty source_file
- source_file doesn't look like a real file path (no extension)
"""
data = G.nodes[node_id]
source = data.get("source_file", "")
if not source:
return True
# Has no file extension → probably a concept label, not a real file
if "." not in source.split("/")[-1]:
return True
return False
def _cross_file_surprises(G: nx.Graph, communities: dict[int, list[str]], top_n: int) -> list[dict]:
"""
Cross-file edges between real code/doc entities.
Excludes concept nodes, file hub nodes, and plain import edges.
Sorted AMBIGUOUS → INFERRED → EXTRACTED.
"""
surprises = []
order = {"AMBIGUOUS": 0, "INFERRED": 1, "EXTRACTED": 2}
for u, v, data in G.edges(data=True):
# Skip structural scaffolding — not insights
relation = data.get("relation", "")
if relation in ("imports", "imports_from", "contains", "method"):
continue
# Skip if either endpoint is a concept or file-level node
if _is_concept_node(G, u) or _is_concept_node(G, v):
continue
if _is_file_node(G, u) or _is_file_node(G, v):
continue
u_source = G.nodes[u].get("source_file", "")
v_source = G.nodes[v].get("source_file", "")
if u_source and v_source and u_source != v_source:
# Respect original edge direction stored in _src/_tgt (if present),
# otherwise fall back to u/v which may be in arbitrary order.
src_id = data.get("_src", u)
tgt_id = data.get("_tgt", v)
surprises.append({
"source": G.nodes[src_id].get("label", src_id),
"target": G.nodes[tgt_id].get("label", tgt_id),
"source_files": [
G.nodes[src_id].get("source_file", ""),
G.nodes[tgt_id].get("source_file", ""),
],
"confidence": data.get("confidence", "EXTRACTED"),
"relation": relation,
})
surprises.sort(key=lambda x: order.get(x["confidence"], 3))
if surprises:
return surprises[:top_n]
# Fallback: no semantic cross-file edges found (pure AST corpus).
# Surface cross-community bridge edges as structural surprises instead.
return _cross_community_surprises(G, communities, top_n)
def _cross_community_surprises(
G: nx.Graph,
communities: dict[int, list[str]],
top_n: int,
) -> list[dict]:
"""
For single-source corpora: find edges that bridge different communities.
These are surprising because Leiden grouped everything else tightly —
these edges cut across the natural structure.
Falls back to high-betweenness edges if no community info is provided.
"""
if not communities:
# No community info — use edge betweenness centrality
if G.number_of_edges() == 0:
return []
betweenness = nx.edge_betweenness_centrality(G)
top_edges = sorted(betweenness.items(), key=lambda x: x[1], reverse=True)[:top_n]
result = []
for (u, v), score in top_edges:
data = G.edges[u, v]
result.append({
"source": G.nodes[u].get("label", u),
"target": G.nodes[v].get("label", v),
"source_files": [
G.nodes[u].get("source_file", ""),
G.nodes[v].get("source_file", ""),
],
"confidence": data.get("confidence", "EXTRACTED"),
"relation": data.get("relation", ""),
"note": f"Bridges graph structure (betweenness={score:.3f})",
})
return result
# Build node → community map
node_community = {n: cid for cid, nodes in communities.items() for n in nodes}
surprises = []
for u, v, data in G.edges(data=True):
cid_u = node_community.get(u)
cid_v = node_community.get(v)
if cid_u is None or cid_v is None or cid_u == cid_v:
continue
# Skip file hub nodes and plain structural edges
if _is_file_node(G, u) or _is_file_node(G, v):
continue
relation = data.get("relation", "")
if relation in ("imports", "imports_from", "contains", "method"):
continue
# This edge crosses community boundaries — interesting
confidence = data.get("confidence", "EXTRACTED")
src_id = data.get("_src", u)
tgt_id = data.get("_tgt", v)
surprises.append({
"source": G.nodes[src_id].get("label", src_id),
"target": G.nodes[tgt_id].get("label", tgt_id),
"source_files": [
G.nodes[src_id].get("source_file", ""),
G.nodes[tgt_id].get("source_file", ""),
],
"confidence": confidence,
"relation": relation,
"note": f"Bridges community {cid_u} → community {cid_v}",
"_pair": tuple(sorted([cid_u, cid_v])),
})
# Sort: AMBIGUOUS first, then INFERRED, then EXTRACTED
order = {"AMBIGUOUS": 0, "INFERRED": 1, "EXTRACTED": 2}
surprises.sort(key=lambda x: order.get(x["confidence"], 3))
# Deduplicate by community pair — one representative edge per (A→B) boundary.
# Without this, a single high-betweenness god node dominates all results.
seen_pairs: set[tuple] = set()
deduped = []
for s in surprises:
pair = s.pop("_pair")
if pair not in seen_pairs:
seen_pairs.add(pair)
deduped.append(s)
return deduped[:top_n]
def suggest_questions(
G: nx.Graph,
communities: dict[int, list[str]],
community_labels: dict[int, str],
top_n: int = 7,
) -> list[dict]:
"""
Generate questions the graph is uniquely positioned to answer.
Based on: AMBIGUOUS edges, bridge nodes, underexplored god nodes, isolated nodes.
Each question has a 'type', 'question', and 'why' field.
"""
questions = []
node_community = {n: cid for cid, nodes in communities.items() for n in nodes}
# 1. AMBIGUOUS edges → unresolved relationship questions
for u, v, data in G.edges(data=True):
if data.get("confidence") == "AMBIGUOUS":
ul = G.nodes[u].get("label", u)
vl = G.nodes[v].get("label", v)
relation = data.get("relation", "related to")
questions.append({
"type": "ambiguous_edge",
"question": f"What is the exact relationship between `{ul}` and `{vl}`?",
"why": f"Edge tagged AMBIGUOUS (relation: {relation}) — confidence is low.",
})
# 2. Bridge nodes (high betweenness) → cross-cutting concern questions
if G.number_of_edges() > 0:
betweenness = nx.betweenness_centrality(G)
# Top bridge nodes that are NOT file-level hubs
bridges = sorted(
[(n, s) for n, s in betweenness.items()
if not _is_file_node(G, n) and not _is_concept_node(G, n) and s > 0],
key=lambda x: x[1],
reverse=True,
)[:3]
for node_id, score in bridges:
label = G.nodes[node_id].get("label", node_id)
cid = node_community.get(node_id)
comm_label = community_labels.get(cid, f"Community {cid}") if cid is not None else "unknown"
neighbors = list(G.neighbors(node_id))
neighbor_comms = {node_community.get(n) for n in neighbors if node_community.get(n) != cid}
if neighbor_comms:
other_labels = [community_labels.get(c, f"Community {c}") for c in neighbor_comms]
questions.append({
"type": "bridge_node",
"question": f"Why does `{label}` connect `{comm_label}` to {', '.join(f'`{l}`' for l in other_labels)}?",
"why": f"High betweenness centrality ({score:.3f}) — this node is a cross-community bridge.",
})
# 3. God nodes with many INFERRED edges → verification questions
degree = dict(G.degree())
top_nodes = sorted(
[(n, d) for n, d in degree.items() if not _is_file_node(G, n)],
key=lambda x: x[1],
reverse=True,
)[:5]
for node_id, _ in top_nodes:
inferred = [
(u, v, d) for u, v, d in G.edges(node_id, data=True)
if d.get("confidence") == "INFERRED"
]
if len(inferred) >= 2:
label = G.nodes[node_id].get("label", node_id)
# Use _src/_tgt to get the correct direction; fall back to v (the other node)
others = []
for u, v, d in inferred[:2]:
src_id = d.get("_src", u)
tgt_id = d.get("_tgt", v)
other_id = tgt_id if src_id == node_id else src_id
others.append(G.nodes[other_id].get("label", other_id))
questions.append({
"type": "verify_inferred",
"question": f"Are the {len(inferred)} inferred relationships involving `{label}` (e.g. with `{others[0]}` and `{others[1]}`) actually correct?",
"why": f"`{label}` has {len(inferred)} INFERRED edges — model-reasoned connections that need verification.",
})
# 4. Isolated or weakly-connected nodes → exploration questions
isolated = [
n for n in G.nodes()
if G.degree(n) <= 1 and not _is_file_node(G, n) and not _is_concept_node(G, n)
]
if isolated:
labels = [G.nodes[n].get("label", n) for n in isolated[:3]]
questions.append({
"type": "isolated_nodes",
"question": f"What connects {', '.join(f'`{l}`' for l in labels)} to the rest of the system?",
"why": f"{len(isolated)} weakly-connected nodes found — possible documentation gaps or missing edges.",
})
# 5. Low-cohesion communities → structural questions
from .cluster import cohesion_score
for cid, nodes in communities.items():
score = cohesion_score(G, nodes)
if score < 0.15 and len(nodes) >= 5:
label = community_labels.get(cid, f"Community {cid}")
questions.append({
"type": "low_cohesion",
"question": f"Should `{label}` be split into smaller, more focused modules?",
"why": f"Cohesion score {score} — nodes in this community are weakly interconnected.",
})
return questions[:top_n]
def graph_diff(G_old: nx.Graph, G_new: nx.Graph) -> dict:
"""Compare two graph snapshots and return what changed.
Returns:
{
"new_nodes": [{"id": ..., "label": ...}],
"removed_nodes": [{"id": ..., "label": ...}],
"new_edges": [{"source": ..., "target": ..., "relation": ..., "confidence": ...}],
"removed_edges": [...],
"summary": "3 new nodes, 5 new edges, 1 node removed"
}
"""
old_nodes = set(G_old.nodes())
new_nodes = set(G_new.nodes())
added_node_ids = new_nodes - old_nodes
removed_node_ids = old_nodes - new_nodes
new_nodes_list = [
{"id": n, "label": G_new.nodes[n].get("label", n)}
for n in added_node_ids
]
removed_nodes_list = [
{"id": n, "label": G_old.nodes[n].get("label", n)}
for n in removed_node_ids
]
def edge_key(G: nx.Graph, u: str, v: str, data: dict) -> tuple:
return (u, v, data.get("relation", ""))
old_edge_keys = {
edge_key(G_old, u, v, d)
for u, v, d in G_old.edges(data=True)
}
new_edge_keys = {
edge_key(G_new, u, v, d)
for u, v, d in G_new.edges(data=True)
}
added_edge_keys = new_edge_keys - old_edge_keys
removed_edge_keys = old_edge_keys - new_edge_keys
new_edges_list = []
for u, v, d in G_new.edges(data=True):
if edge_key(G_new, u, v, d) in added_edge_keys:
new_edges_list.append({
"source": u,
"target": v,
"relation": d.get("relation", ""),
"confidence": d.get("confidence", ""),
})
removed_edges_list = []
for u, v, d in G_old.edges(data=True):
if edge_key(G_old, u, v, d) in removed_edge_keys:
removed_edges_list.append({
"source": u,
"target": v,
"relation": d.get("relation", ""),
"confidence": d.get("confidence", ""),
})
parts = []
if new_nodes_list:
parts.append(f"{len(new_nodes_list)} new node{'s' if len(new_nodes_list) != 1 else ''}")
if new_edges_list:
parts.append(f"{len(new_edges_list)} new edge{'s' if len(new_edges_list) != 1 else ''}")
if removed_nodes_list:
parts.append(f"{len(removed_nodes_list)} node{'s' if len(removed_nodes_list) != 1 else ''} removed")
if removed_edges_list:
parts.append(f"{len(removed_edges_list)} edge{'s' if len(removed_edges_list) != 1 else ''} removed")
summary = ", ".join(parts) if parts else "no changes"
return {
"new_nodes": new_nodes_list,
"removed_nodes": removed_nodes_list,
"new_edges": new_edges_list,
"removed_edges": removed_edges_list,
"summary": summary,
}
@@ -1,3 +1,4 @@
# assemble node+edge dicts into a NetworkX graph, preserving edge direction
from __future__ import annotations
import networkx as nx
@@ -7,11 +8,12 @@ def build_from_json(extraction: dict) -> nx.Graph:
for node in extraction.get("nodes", []):
G.add_node(node["id"], **{k: v for k, v in node.items() if k != "id"})
for edge in extraction.get("edges", []):
G.add_edge(
edge["source"],
edge["target"],
**{k: v for k, v in edge.items() if k not in ("source", "target")},
)
attrs = {k: v for k, v in edge.items() if k not in ("source", "target")}
# Preserve original edge direction — undirected graphs lose it otherwise,
# causing display functions to show edges backwards.
attrs["_src"] = edge["source"]
attrs["_tgt"] = edge["target"]
G.add_edge(edge["source"], edge["target"], **attrs)
return G
+63
View File
@@ -0,0 +1,63 @@
# per-file extraction cache — skip unchanged files on re-run
from __future__ import annotations
import hashlib
import json
import shutil
from pathlib import Path
def file_hash(path: Path) -> str:
"""SHA256 of file contents, hex digest."""
return hashlib.sha256(Path(path).read_bytes()).hexdigest()
def cache_dir(root: Path = Path(".")) -> Path:
"""Returns .graphify/cache/ — creates it if needed."""
d = Path(root) / ".graphify" / "cache"
d.mkdir(parents=True, exist_ok=True)
return d
def load_cached(path: Path, root: Path = Path(".")) -> dict | None:
"""Return cached extraction for this file if hash matches, else None.
Cache key: SHA256 of file contents.
Cache value: stored as .graphify/cache/{hash}.json
Returns None if no cache entry or file has changed.
"""
try:
h = file_hash(path)
except OSError:
return None
entry = cache_dir(root) / f"{h}.json"
if not entry.exists():
return None
try:
return json.loads(entry.read_text())
except (json.JSONDecodeError, OSError):
return None
def save_cached(path: Path, result: dict, root: Path = Path(".")) -> None:
"""Save extraction result for this file.
Stores as .graphify/cache/{hash}.json where hash = SHA256 of current file contents.
result should be a dict with 'nodes' and 'edges' lists.
"""
h = file_hash(path)
entry = cache_dir(root) / f"{h}.json"
entry.write_text(json.dumps(result))
def cached_files(root: Path = Path(".")) -> set[str]:
"""Return set of file paths that have a valid cache entry (hash still matches)."""
d = cache_dir(root)
return {p.stem for p in d.glob("*.json")}
def clear_cache(root: Path = Path(".")) -> None:
"""Delete all .graphify/cache/*.json files."""
d = cache_dir(root)
for f in d.glob("*.json"):
f.unlink()
@@ -1,7 +1,26 @@
"""Leiden community detection on NetworkX graphs. Splits oversized communities. Returns cohesion scores."""
from __future__ import annotations
import networkx as nx
from graspologic.partition import leiden
def build_graph(nodes: list[dict], edges: list[dict]) -> nx.Graph:
"""Build a NetworkX graph from graphify node/edge dicts.
Preserves original edge direction as _src/_tgt attributes so that
display functions can show relationships in the correct direction,
even though the graph is undirected for structural analysis.
"""
G = nx.Graph()
for n in nodes:
G.add_node(n["id"], **{k: v for k, v in n.items() if k != "id"})
for e in edges:
attrs = {k: v for k, v in e.items() if k not in ("source", "target")}
attrs["_src"] = e["source"]
attrs["_tgt"] = e["target"]
G.add_edge(e["source"], e["target"], **attrs)
return G
_MAX_COMMUNITY_FRACTION = 0.25 # communities larger than 25% of graph get split
_MIN_SPLIT_SIZE = 10 # only split if community has at least this many nodes
+247
View File
@@ -0,0 +1,247 @@
# file discovery, type classification, and corpus health checks
from __future__ import annotations
import json
import re
from enum import Enum
from pathlib import Path
class FileType(str, Enum):
CODE = "code"
DOCUMENT = "document"
PAPER = "paper"
IMAGE = "image"
_MANIFEST_PATH = ".graphify/manifest.json"
CODE_EXTENSIONS = {'.py', '.ts', '.js', '.go', '.rs', '.java', '.cpp', '.c', '.rb', '.swift', '.kt'}
DOC_EXTENSIONS = {'.md', '.txt', '.rst'}
PAPER_EXTENSIONS = {'.pdf'}
IMAGE_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg'}
CORPUS_WARN_THRESHOLD = 50_000 # words — below this, warn "you may not need a graph"
CORPUS_UPPER_THRESHOLD = 500_000 # words — above this, warn about token cost
FILE_COUNT_UPPER = 200 # files — above this, warn about token cost
# Files that may contain secrets — skip silently
_SENSITIVE_PATTERNS = [
re.compile(r'(^|[\\/])\.(env|envrc)(\.|$)', re.IGNORECASE),
re.compile(r'\.(pem|key|p12|pfx|cert|crt|der|p8)$', re.IGNORECASE),
re.compile(r'(credential|secret|passwd|password|token|private_key)', re.IGNORECASE),
re.compile(r'(id_rsa|id_dsa|id_ecdsa|id_ed25519)(\.pub)?$'),
re.compile(r'(\.netrc|\.pgpass|\.htpasswd)$', re.IGNORECASE),
re.compile(r'(aws_credentials|gcloud_credentials|service.account)', re.IGNORECASE),
]
# Signals that a .md/.txt file is actually a converted academic paper
_PAPER_SIGNALS = [
re.compile(r'\barxiv\b', re.IGNORECASE),
re.compile(r'\bdoi\s*:', re.IGNORECASE),
re.compile(r'\babstract\b', re.IGNORECASE),
re.compile(r'\bproceedings\b', re.IGNORECASE),
re.compile(r'\bjournal\b', re.IGNORECASE),
re.compile(r'\bpreprint\b', re.IGNORECASE),
re.compile(r'\\cite\{'), # LaTeX citation
re.compile(r'\[\d+\]'), # Numbered citation [1], [23] (inline)
re.compile(r'\[\n\d+\n\]'), # Numbered citation spread across lines (markdown conversion)
re.compile(r'eq\.\s*\d+|equation\s+\d+', re.IGNORECASE),
re.compile(r'\d{4}\.\d{4,5}'), # arXiv ID like 1706.03762
re.compile(r'\bwe propose\b', re.IGNORECASE), # common academic phrasing
re.compile(r'\bliterature\b', re.IGNORECASE), # "from the literature"
]
_PAPER_SIGNAL_THRESHOLD = 3 # need at least this many signals to call it a paper
def _is_sensitive(path: Path) -> bool:
"""Return True if this file likely contains secrets and should be skipped."""
name = path.name
full = str(path)
return any(p.search(name) or p.search(full) for p in _SENSITIVE_PATTERNS)
def _looks_like_paper(path: Path) -> bool:
"""Heuristic: does this text file read like an academic paper?"""
try:
# Only scan first 3000 chars for speed
text = path.read_text(errors="ignore")[:3000]
hits = sum(1 for pattern in _PAPER_SIGNALS if pattern.search(text))
return hits >= _PAPER_SIGNAL_THRESHOLD
except Exception:
return False
def classify_file(path: Path) -> FileType | None:
ext = path.suffix.lower()
if ext in CODE_EXTENSIONS:
return FileType.CODE
if ext in PAPER_EXTENSIONS:
return FileType.PAPER
if ext in IMAGE_EXTENSIONS:
return FileType.IMAGE
if ext in DOC_EXTENSIONS:
# Check if it's a converted paper
if _looks_like_paper(path):
return FileType.PAPER
return FileType.DOCUMENT
return None
def extract_pdf_text(path: Path) -> str:
"""Extract plain text from a PDF file using pypdf."""
try:
from pypdf import PdfReader
reader = PdfReader(str(path))
pages = []
for page in reader.pages:
text = page.extract_text()
if text:
pages.append(text)
return "\n".join(pages)
except Exception:
return ""
def count_words(path: Path) -> int:
try:
if path.suffix.lower() == ".pdf":
return len(extract_pdf_text(path).split())
return len(path.read_text(errors="ignore").split())
except Exception:
return 0
# Directory names to always skip — venvs, caches, build artifacts, deps
_SKIP_DIRS = {
"venv", ".venv", "env", ".env",
"node_modules", "__pycache__", ".git",
"dist", "build", "target", "out",
"site-packages", "lib64",
".pytest_cache", ".mypy_cache", ".ruff_cache",
".tox", ".eggs", "*.egg-info",
}
def _is_noise_dir(part: str) -> bool:
"""Return True if this directory name looks like a venv, cache, or dep dir."""
if part in _SKIP_DIRS:
return True
# Catch *_venv, *_repo/site-packages patterns
if part.endswith("_venv") or part.endswith("_env"):
return True
if part.endswith(".egg-info"):
return True
return False
def detect(root: Path) -> dict:
files: dict[FileType, list[str]] = {
FileType.CODE: [],
FileType.DOCUMENT: [],
FileType.PAPER: [],
FileType.IMAGE: [],
}
total_words = 0
skipped_sensitive: list[str] = []
for p in sorted(root.rglob("*")):
if not p.is_file():
continue
parts = p.relative_to(root).parts
# Skip hidden dirs and known noise dirs
if any(part.startswith(".") or _is_noise_dir(part) for part in parts):
continue
if _is_sensitive(p):
skipped_sensitive.append(str(p))
continue
ftype = classify_file(p)
if ftype:
files[ftype].append(str(p))
total_words += count_words(p)
total_files = sum(len(v) for v in files.values())
needs_graph = total_words >= CORPUS_WARN_THRESHOLD
# Determine warning — lower bound, upper bound, or sensitive files skipped
warning: str | None = None
if not needs_graph:
warning = (
f"Corpus is ~{total_words:,} words — fits in a single context window. "
f"You may not need a graph."
)
elif total_words >= CORPUS_UPPER_THRESHOLD or total_files >= FILE_COUNT_UPPER:
warning = (
f"Large corpus: {total_files} files · ~{total_words:,} words. "
f"Semantic extraction will be expensive (many Claude tokens). "
f"Consider running on a subfolder, or use --no-semantic to run AST-only."
)
return {
"files": {k.value: v for k, v in files.items()},
"total_files": total_files,
"total_words": total_words,
"needs_graph": needs_graph,
"warning": warning,
"skipped_sensitive": skipped_sensitive,
}
def load_manifest(manifest_path: str = _MANIFEST_PATH) -> dict[str, float]:
"""Load the file modification time manifest from a previous run."""
try:
return json.loads(Path(manifest_path).read_text())
except Exception:
return {}
def save_manifest(files: dict[str, list[str]], manifest_path: str = _MANIFEST_PATH) -> None:
"""Save current file mtimes so the next --update run can diff against them."""
manifest: dict[str, float] = {}
for file_list in files.values():
for f in file_list:
try:
manifest[f] = Path(f).stat().st_mtime
except Exception:
pass
Path(manifest_path).parent.mkdir(parents=True, exist_ok=True)
Path(manifest_path).write_text(json.dumps(manifest, indent=2))
def detect_incremental(root: Path, manifest_path: str = _MANIFEST_PATH) -> dict:
"""Like detect(), but returns only new or modified files since the last run.
Compares current file mtimes against the stored manifest.
Use for --update mode: re-extract only what changed, merge into existing graph.
"""
full = detect(root)
manifest = load_manifest(manifest_path)
if not manifest:
# No previous run — treat everything as new
full["incremental"] = True
full["new_files"] = full["files"]
full["unchanged_files"] = {k: [] for k in full["files"]}
full["new_total"] = full["total_files"]
return full
new_files: dict[str, list[str]] = {k: [] for k in full["files"]}
unchanged_files: dict[str, list[str]] = {k: [] for k in full["files"]}
for ftype, file_list in full["files"].items():
for f in file_list:
stored_mtime = manifest.get(f)
try:
current_mtime = Path(f).stat().st_mtime
except Exception:
current_mtime = 0
if stored_mtime is None or current_mtime > stored_mtime:
new_files[ftype].append(f)
else:
unchanged_files[ftype].append(f)
new_total = sum(len(v) for v in new_files.values())
full["incremental"] = True
full["new_files"] = new_files
full["unchanged_files"] = unchanged_files
full["new_total"] = new_total
return full
+438
View File
@@ -0,0 +1,438 @@
# write graph to HTML, JSON, SVG, Obsidian vault, and Neo4j Cypher
from __future__ import annotations
import json
import re
from pathlib import Path
import networkx as nx
from networkx.readwrite import json_graph
COMMUNITY_COLORS = [
"#4E79A7", "#F28E2B", "#E15759", "#76B7B2", "#59A14F",
"#EDC948", "#B07AA1", "#FF9DA7", "#9C755F", "#BAB0AC",
]
MAX_NODES_FOR_VIZ = 5_000
def to_json(G: nx.Graph, communities: dict[int, list[str]], output_path: str) -> None:
node_community = {n: cid for cid, nodes in communities.items() for n in nodes}
data = json_graph.node_link_data(G, edges="links")
for node in data["nodes"]:
node["community"] = node_community.get(node["id"])
with open(output_path, "w") as f:
json.dump(data, f, indent=2)
def to_cypher(G: nx.Graph, output_path: str) -> None:
lines = ["// Neo4j Cypher import — generated by /graphify", ""]
for node_id, data in G.nodes(data=True):
label = data.get("label", node_id).replace("'", "\\'")
ftype = data.get("file_type", "unknown").capitalize()
lines.append(f"MERGE (n:{ftype} {{id: '{node_id}', label: '{label}'}});")
lines.append("")
for u, v, data in G.edges(data=True):
rel = data.get("relation", "RELATES_TO").upper().replace(" ", "_").replace("-", "_")
conf = data.get("confidence", "EXTRACTED")
lines.append(
f"MATCH (a {{id: '{u}'}}), (b {{id: '{v}'}}) "
f"MERGE (a)-[:{rel} {{confidence: '{conf}'}}]->(b);"
)
with open(output_path, "w") as f:
f.write("\n".join(lines))
def to_html(
G: nx.Graph,
communities: dict[int, list[str]],
output_path: str,
community_labels: dict[int, str] | None = None,
) -> None:
"""Generate an interactive pyvis HTML visualization of the graph.
Merged from visualizer.py. Raises ValueError if graph exceeds MAX_NODES_FOR_VIZ.
"""
from pyvis.network import Network
if G.number_of_nodes() > MAX_NODES_FOR_VIZ:
raise ValueError(
f"Graph has {G.number_of_nodes()} nodes — too large for pyvis. "
f"Use --no-viz or reduce input size."
)
node_community = {n: cid for cid, nodes in communities.items() for n in nodes}
net = Network(height="800px", width="100%", bgcolor="#1a1a2e", font_color="white")
net.barnes_hut()
for node_id, data in G.nodes(data=True):
cid = node_community.get(node_id, 0)
color = COMMUNITY_COLORS[cid % len(COMMUNITY_COLORS)]
net.add_node(
node_id,
label=data.get("label", node_id),
color=color,
title=(
f"Source: {data.get('source_file', 'unknown')}\n"
f"Type: {data.get('file_type', 'unknown')}\n"
f"Community: {community_labels.get(cid, str(cid)) if community_labels else cid}"
),
)
for u, v, data in G.edges(data=True):
confidence = data.get("confidence", "EXTRACTED")
width = {"EXTRACTED": 2, "INFERRED": 1, "AMBIGUOUS": 1}.get(confidence, 1)
net.add_edge(
u, v,
title=f"{data.get('relation', '')} [{confidence}]",
width=width,
dashes=(confidence != "EXTRACTED"),
)
net.save_graph(output_path)
# Inject community legend into saved HTML
if community_labels:
legend_items = ""
for cid in sorted(community_labels.keys()):
color = COMMUNITY_COLORS[cid % len(COMMUNITY_COLORS)]
label = community_labels[cid]
n_nodes = len(communities.get(cid, []))
legend_items += (
f'<div style="margin:4px 0">'
f'<span style="color:{color};font-size:18px">■</span> '
f'<span style="font-size:13px">{label} ({n_nodes})</span>'
f'</div>'
)
legend_html = (
'<div style="position:fixed;top:10px;right:10px;background:#2a2a4e;'
'padding:12px 16px;border-radius:8px;font-family:sans-serif;color:white;'
'z-index:9999;min-width:180px;">'
'<b style="font-size:14px">Communities</b><br>'
+ legend_items +
'</div>'
)
content = Path(output_path).read_text()
content = content.replace("</body>", legend_html + "\n</body>")
Path(output_path).write_text(content)
# Keep backward-compatible alias — skill.md calls generate_html
generate_html = to_html
def to_obsidian(
G: nx.Graph,
communities: dict[int, list[str]],
output_dir: str,
community_labels: dict[int, str] | None = None,
cohesion: dict[int, float] | None = None,
) -> int:
"""Export graph as an Obsidian vault — one .md file per node with [[wikilinks]],
plus one _COMMUNITY_name.md overview note per community (sorted to top by underscore prefix).
Open the output directory as a vault in Obsidian to get an interactive
graph view with community colors and full-text search over node metadata.
Returns the number of node notes + community notes written.
"""
out = Path(output_dir)
out.mkdir(parents=True, exist_ok=True)
node_community = {n: cid for cid, nodes in communities.items() for n in nodes}
# Map node_id → safe filename so wikilinks stay consistent.
# Deduplicate: if two nodes produce the same filename, append a numeric suffix.
def safe_name(label: str) -> str:
return re.sub(r'[\\/*?:"<>|#^[\]]', "", label).strip() or "unnamed"
node_filename: dict[str, str] = {}
seen_names: dict[str, int] = {}
for node_id, data in G.nodes(data=True):
base = safe_name(data.get("label", node_id))
if base in seen_names:
seen_names[base] += 1
node_filename[node_id] = f"{base}_{seen_names[base]}"
else:
seen_names[base] = 0
node_filename[node_id] = base
# Write one .md file per node
for node_id, data in G.nodes(data=True):
label = data.get("label", node_id)
cid = node_community.get(node_id)
community_name = (
community_labels.get(cid, f"Community {cid}")
if community_labels and cid is not None
else f"Community {cid}"
)
lines: list[str] = []
# YAML frontmatter — readable in Obsidian's properties panel
lines += [
"---",
f'source_file: "{data.get("source_file", "")}"',
f'type: "{data.get("file_type", "")}"',
f'community: "{community_name}"',
]
if data.get("source_location"):
lines.append(f'location: "{data["source_location"]}"')
lines += ["---", "", f"# {label}", ""]
# Outgoing edges as wikilinks
neighbors = list(G.neighbors(node_id))
if neighbors:
lines.append("## Connections")
for neighbor in sorted(neighbors, key=lambda n: G.nodes[n].get("label", n)):
edge_data = G.edges[node_id, neighbor]
neighbor_label = node_filename[neighbor]
relation = edge_data.get("relation", "")
confidence = edge_data.get("confidence", "EXTRACTED")
lines.append(f"- [[{neighbor_label}]] — `{relation}` [{confidence}]")
fname = node_filename[node_id] + ".md"
(out / fname).write_text("\n".join(lines), encoding="utf-8")
# Write one _COMMUNITY_name.md overview note per community
# Build inter-community edge counts for "Connections to other communities"
inter_community_edges: dict[int, dict[int, int]] = {}
for cid in communities:
inter_community_edges[cid] = {}
for u, v in G.edges():
cu = node_community.get(u)
cv = node_community.get(v)
if cu is not None and cv is not None and cu != cv:
inter_community_edges.setdefault(cu, {})
inter_community_edges.setdefault(cv, {})
inter_community_edges[cu][cv] = inter_community_edges[cu].get(cv, 0) + 1
inter_community_edges[cv][cu] = inter_community_edges[cv].get(cu, 0) + 1
# Precompute per-node community reach (number of distinct communities a node connects to)
def _community_reach(node_id: str) -> int:
neighbor_cids = {
node_community[nb]
for nb in G.neighbors(node_id)
if nb in node_community and node_community[nb] != node_community.get(node_id)
}
return len(neighbor_cids)
community_notes_written = 0
for cid, members in communities.items():
community_name = (
community_labels.get(cid, f"Community {cid}")
if community_labels and cid is not None
else f"Community {cid}"
)
n_members = len(members)
coh_value = cohesion.get(cid) if cohesion else None
lines: list[str] = []
# YAML frontmatter
lines.append("---")
lines.append("type: community")
if coh_value is not None:
lines.append(f"cohesion: {coh_value:.2f}")
lines.append(f"members: {n_members}")
lines.append("---")
lines.append("")
lines.append(f"# {community_name}")
lines.append("")
# Cohesion + member count summary
if coh_value is not None:
cohesion_desc = (
"tightly connected" if coh_value >= 0.7
else "moderately connected" if coh_value >= 0.4
else "loosely connected"
)
lines.append(f"**Cohesion:** {coh_value:.2f}{cohesion_desc}")
lines.append(f"**Members:** {n_members} nodes")
lines.append("")
# Members section
lines.append("## Members")
for node_id in sorted(members, key=lambda n: G.nodes[n].get("label", n)):
data = G.nodes[node_id]
node_label = node_filename[node_id]
ftype = data.get("file_type", "")
source = data.get("source_file", "")
entry = f"- [[{node_label}]]"
if ftype:
entry += f"{ftype}"
if source:
entry += f"{source}"
lines.append(entry)
lines.append("")
# Connections to other communities
cross = inter_community_edges.get(cid, {})
if cross:
lines.append("## Connections to other communities")
for other_cid, edge_count in sorted(cross.items(), key=lambda x: -x[1]):
other_name = (
community_labels.get(other_cid, f"Community {other_cid}")
if community_labels and other_cid is not None
else f"Community {other_cid}"
)
other_safe = safe_name(other_name)
lines.append(f"- {edge_count} edge{'s' if edge_count != 1 else ''} to [[_COMMUNITY_{other_safe}]]")
lines.append("")
# Top bridge nodes — highest degree nodes that connect to other communities
bridge_nodes = [
(node_id, G.degree(node_id), _community_reach(node_id))
for node_id in members
if _community_reach(node_id) > 0
]
bridge_nodes.sort(key=lambda x: (-x[2], -x[1]))
top_bridges = bridge_nodes[:5]
if top_bridges:
lines.append("## Top bridge nodes")
for node_id, degree, reach in top_bridges:
node_label = node_filename[node_id]
lines.append(
f"- [[{node_label}]] — degree {degree}, connects to {reach} "
f"{'community' if reach == 1 else 'communities'}"
)
community_safe = safe_name(community_name)
fname = f"_COMMUNITY_{community_safe}.md"
(out / fname).write_text("\n".join(lines), encoding="utf-8")
community_notes_written += 1
return G.number_of_nodes() + community_notes_written
def push_to_neo4j(
G: nx.Graph,
uri: str,
user: str,
password: str,
communities: dict[int, list[str]] | None = None,
) -> dict[str, int]:
"""Push graph directly to a running Neo4j instance via the Python driver.
Requires: pip install neo4j
Uses MERGE so re-running is safe — nodes and edges are upserted, not duplicated.
Returns a dict with counts of nodes and edges pushed.
"""
try:
from neo4j import GraphDatabase
except ImportError as e:
raise ImportError(
"neo4j driver not installed. Run: pip install neo4j"
) from e
node_community = (
{n: cid for cid, nodes in communities.items() for n in nodes}
if communities else {}
)
def _safe_rel(relation: str) -> str:
return re.sub(r"[^A-Z0-9_]", "_", relation.upper().replace(" ", "_").replace("-", "_")) or "RELATED_TO"
driver = GraphDatabase.driver(uri, auth=(user, password))
nodes_pushed = 0
edges_pushed = 0
with driver.session() as session:
for node_id, data in G.nodes(data=True):
props = {k: v for k, v in data.items() if isinstance(v, (str, int, float, bool))}
props["id"] = node_id
cid = node_community.get(node_id)
if cid is not None:
props["community"] = cid
ftype = data.get("file_type", "Entity").capitalize()
session.run(
f"MERGE (n:{ftype} {{id: $id}}) SET n += $props",
id=node_id,
props=props,
)
nodes_pushed += 1
for u, v, data in G.edges(data=True):
rel = _safe_rel(data.get("relation", "RELATED_TO"))
props = {k: v for k, v in data.items() if isinstance(v, (str, int, float, bool))}
session.run(
f"MATCH (a {{id: $src}}), (b {{id: $tgt}}) "
f"MERGE (a)-[r:{rel}]->(b) SET r += $props",
src=u,
tgt=v,
props=props,
)
edges_pushed += 1
driver.close()
return {"nodes": nodes_pushed, "edges": edges_pushed}
def to_svg(
G: nx.Graph,
communities: dict[int, list[str]],
output_path: str,
community_labels: dict[int, str] | None = None,
figsize: tuple[int, int] = (20, 14),
) -> None:
"""Export graph as an SVG file using matplotlib + spring layout.
Lightweight and embeddable — works in Obsidian notes, Notion, GitHub READMEs,
and any markdown renderer. No JavaScript required.
Node size scales with degree. Community colors match the pyvis HTML output.
"""
try:
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
except ImportError as e:
raise ImportError("matplotlib not installed. Run: pip install matplotlib") from e
node_community = {n: cid for cid, nodes in communities.items() for n in nodes}
fig, ax = plt.subplots(figsize=figsize, facecolor="#1a1a2e")
ax.set_facecolor("#1a1a2e")
ax.axis("off")
pos = nx.spring_layout(G, seed=42, k=2.0 / (G.number_of_nodes() ** 0.5 + 1))
degree = dict(G.degree())
max_deg = max(degree.values()) if degree else 1
node_colors = [COMMUNITY_COLORS[node_community.get(n, 0) % len(COMMUNITY_COLORS)] for n in G.nodes()]
node_sizes = [300 + 1200 * (degree.get(n, 1) / max_deg) for n in G.nodes()]
# Draw edges — dashed for non-EXTRACTED
for u, v, data in G.edges(data=True):
conf = data.get("confidence", "EXTRACTED")
style = "solid" if conf == "EXTRACTED" else "dashed"
alpha = 0.6 if conf == "EXTRACTED" else 0.3
x0, y0 = pos[u]
x1, y1 = pos[v]
ax.plot([x0, x1], [y0, y1], color="#aaaaaa", linewidth=0.8,
linestyle=style, alpha=alpha, zorder=1)
nx.draw_networkx_nodes(G, pos, ax=ax, node_color=node_colors,
node_size=node_sizes, alpha=0.9)
nx.draw_networkx_labels(G, pos, ax=ax,
labels={n: G.nodes[n].get("label", n) for n in G.nodes()},
font_size=7, font_color="white")
# Legend
if community_labels:
patches = [
mpatches.Patch(
color=COMMUNITY_COLORS[cid % len(COMMUNITY_COLORS)],
label=f"{label} ({len(communities.get(cid, []))})",
)
for cid, label in sorted(community_labels.items())
]
ax.legend(handles=patches, loc="upper left", framealpha=0.7,
facecolor="#2a2a4e", labelcolor="white", fontsize=8)
plt.tight_layout()
plt.savefig(output_path, format="svg", bbox_inches="tight",
facecolor=fig.get_facecolor())
plt.close(fig)
+343
View File
@@ -0,0 +1,343 @@
"""Deterministic structural extraction from Python code using tree-sitter. Outputs nodes+edges dicts."""
from __future__ import annotations
import json
import re
import sys
from pathlib import Path
def _make_id(*parts: str) -> str:
"""Build a stable node ID from one or more name parts."""
combined = "_".join(p.strip("_.") for p in parts if p)
cleaned = re.sub(r"[^a-zA-Z0-9]+", "_", combined)
return cleaned.strip("_").lower()
def extract_python(path: Path) -> dict:
"""Extract classes, functions, and imports from a .py file via tree-sitter AST."""
try:
import tree_sitter_python as tspython
from tree_sitter import Language, Parser
except ImportError:
return {"nodes": [], "edges": [], "error": "tree-sitter-python not installed"}
try:
language = Language(tspython.language())
parser = Parser(language)
source = path.read_bytes()
tree = parser.parse(source)
root = tree.root_node
except Exception as e:
return {"nodes": [], "edges": [], "error": str(e)}
stem = path.stem
str_path = str(path)
nodes: list[dict] = []
edges: list[dict] = []
seen_ids: set[str] = set()
def add_node(nid: str, label: str, line: int) -> None:
if nid not in seen_ids:
seen_ids.add(nid)
nodes.append({
"id": nid,
"label": label,
"file_type": "code",
"source_file": str_path,
"source_location": f"L{line}",
})
def add_edge(src: str, tgt: str, relation: str, line: int) -> None:
# Only add edge if both endpoints exist or src is the file node
edges.append({
"source": src,
"target": tgt,
"relation": relation,
"confidence": "EXTRACTED",
"source_file": str_path,
"source_location": f"L{line}",
"weight": 1.0,
})
# File-level node — stable ID based on stem only
file_nid = _make_id(stem)
add_node(file_nid, path.name, 1)
def walk(node, parent_class_nid: str | None = None) -> None:
t = node.type
if t == "import_statement":
for child in node.children:
if child.type in ("dotted_name", "aliased_import"):
raw = source[child.start_byte:child.end_byte].decode()
module_name = raw.split(" as ")[0].strip().lstrip(".")
tgt_nid = _make_id(module_name)
add_edge(file_nid, tgt_nid, "imports", node.start_point[0] + 1)
return
if t == "import_from_statement":
module_node = node.child_by_field_name("module_name")
if module_node:
raw = source[module_node.start_byte:module_node.end_byte].decode().lstrip(".")
tgt_nid = _make_id(raw)
add_edge(file_nid, tgt_nid, "imports_from", node.start_point[0] + 1)
return
if t == "class_definition":
name_node = node.child_by_field_name("name")
if not name_node:
return
class_name = source[name_node.start_byte:name_node.end_byte].decode()
class_nid = _make_id(stem, class_name)
line = node.start_point[0] + 1
add_node(class_nid, class_name, line)
add_edge(file_nid, class_nid, "contains", line)
# Inheritance — create stub node for external bases so the edge is never dropped
args = node.child_by_field_name("superclasses")
if args:
for arg in args.children:
if arg.type == "identifier":
base = source[arg.start_byte:arg.end_byte].decode()
# Try same-file base first; fall back to a bare stub
base_nid = _make_id(stem, base)
if base_nid not in seen_ids:
# External or forward-declared base — add a stub so edge survives
base_nid = _make_id(base)
if base_nid not in seen_ids:
nodes.append({
"id": base_nid,
"label": base,
"file_type": "code",
"source_file": "",
"source_location": "",
})
seen_ids.add(base_nid)
add_edge(class_nid, base_nid, "inherits", line)
# Walk class body for methods
body = node.child_by_field_name("body")
if body:
for child in body.children:
walk(child, parent_class_nid=class_nid)
return
if t == "function_definition":
name_node = node.child_by_field_name("name")
if not name_node:
return
func_name = source[name_node.start_byte:name_node.end_byte].decode()
line = node.start_point[0] + 1
if parent_class_nid:
func_nid = _make_id(parent_class_nid, func_name)
add_node(func_nid, f".{func_name}()", line)
add_edge(parent_class_nid, func_nid, "method", line)
else:
func_nid = _make_id(stem, func_name)
add_node(func_nid, f"{func_name}()", line)
add_edge(file_nid, func_nid, "contains", line)
return
for child in node.children:
walk(child, parent_class_nid=None)
walk(root)
# Post-process: remove edges whose source or target was never added as a node
# (dangling import edges pointing to external libraries are fine to keep,
# but edges between internal entities must be valid)
valid_ids = seen_ids
clean_edges = []
for edge in edges:
src, tgt = edge["source"], edge["target"]
# Keep if both endpoints are known, OR if it's an import edge (tgt may be external)
if src in valid_ids and (tgt in valid_ids or edge["relation"] in ("imports", "imports_from")):
clean_edges.append(edge)
return {"nodes": nodes, "edges": clean_edges}
def _resolve_cross_file_imports(
per_file: list[dict],
paths: list[Path],
) -> list[dict]:
"""
Two-pass import resolution: turn file-level imports into class-level edges.
Pass 1 build a global map: class/function name node_id, per stem.
Pass 2 for each `from .module import Name`, look up Name in the global
map and add a direct INFERRED edge from each class in the
importing file to the imported entity.
This turns:
auth.py --imports_from--> models.py (obvious, filtered out)
Into:
DigestAuth --uses--> Response [INFERRED] (cross-file, interesting!)
BasicAuth --uses--> Request [INFERRED]
"""
try:
import tree_sitter_python as tspython
from tree_sitter import Language, Parser
except ImportError:
return []
language = Language(tspython.language())
parser = Parser(language)
# Pass 1: name → node_id across all files
# Map: stem → {ClassName: node_id}
stem_to_entities: dict[str, dict[str, str]] = {}
for file_result in per_file:
for node in file_result.get("nodes", []):
src = node.get("source_file", "")
if not src:
continue
stem = Path(src).stem
label = node.get("label", "")
nid = node.get("id", "")
# Only index real classes/functions (not file nodes, not method stubs)
if label and not label.endswith((")", ".py")) and "_" not in label[:1]:
stem_to_entities.setdefault(stem, {})[label] = nid
# Pass 2: for each file, find `from .X import A, B, C` and resolve
new_edges: list[dict] = []
stem_to_path: dict[str, Path] = {p.stem: p for p in paths}
for file_result, path in zip(per_file, paths):
stem = path.stem
str_path = str(path)
# Find all classes defined in this file (the importers)
local_classes = [
n["id"] for n in file_result.get("nodes", [])
if n.get("source_file") == str_path
and not n["label"].endswith((")", ".py"))
and n["id"] != _make_id(stem) # exclude file-level node
]
if not local_classes:
continue
# Parse imports from this file
try:
source = path.read_bytes()
tree = parser.parse(source)
except Exception:
continue
def walk_imports(node) -> None:
if node.type == "import_from_statement":
# Find the module name — handles both absolute and relative imports.
# Relative: `from .models import X` → relative_import → dotted_name
# Absolute: `from models import X` → module_name field
target_stem: str | None = None
for child in node.children:
if child.type == "relative_import":
# Dig into relative_import → dotted_name → identifier
for sub in child.children:
if sub.type == "dotted_name":
raw = source[sub.start_byte:sub.end_byte].decode()
target_stem = raw.split(".")[-1]
break
break
if child.type == "dotted_name" and target_stem is None:
raw = source[child.start_byte:child.end_byte].decode()
target_stem = raw.split(".")[-1]
if not target_stem or target_stem not in stem_to_entities:
return
# Collect imported names: dotted_name children of import_from_statement
# that come AFTER the 'import' keyword token.
imported_names: list[str] = []
past_import_kw = False
for child in node.children:
if child.type == "import":
past_import_kw = True
continue
if not past_import_kw:
continue
if child.type == "dotted_name":
imported_names.append(
source[child.start_byte:child.end_byte].decode()
)
elif child.type == "aliased_import":
# `import X as Y` — take the original name
name_node = child.child_by_field_name("name")
if name_node:
imported_names.append(
source[name_node.start_byte:name_node.end_byte].decode()
)
line = node.start_point[0] + 1
for name in imported_names:
tgt_nid = stem_to_entities[target_stem].get(name)
if tgt_nid:
for src_class_nid in local_classes:
new_edges.append({
"source": src_class_nid,
"target": tgt_nid,
"relation": "uses",
"confidence": "INFERRED",
"source_file": str_path,
"source_location": f"L{line}",
"weight": 0.8,
})
for child in node.children:
walk_imports(child)
walk_imports(tree.root_node)
return new_edges
def extract(paths: list[Path]) -> dict:
"""Extract AST nodes and edges from a list of code files.
Two-pass process:
1. Per-file structural extraction (classes, functions, imports)
2. Cross-file import resolution: turns file-level imports into
class-level INFERRED edges (DigestAuth --uses--> Response)
"""
per_file: list[dict] = []
for path in paths:
if path.suffix == ".py":
result = extract_python(path)
per_file.append(result)
all_nodes: list[dict] = []
all_edges: list[dict] = []
for result in per_file:
all_nodes.extend(result.get("nodes", []))
all_edges.extend(result.get("edges", []))
# Add cross-file class-level edges
cross_file_edges = _resolve_cross_file_imports(per_file, paths)
all_edges.extend(cross_file_edges)
return {
"nodes": all_nodes,
"edges": all_edges,
"input_tokens": 0,
"output_tokens": 0,
}
def collect_files(target: Path) -> list[Path]:
if target.is_file():
return [target]
return sorted(p for p in target.rglob("*.py")
if not any(part.startswith(".") for part in p.parts))
if __name__ == "__main__":
if len(sys.argv) < 2:
print("Usage: python -m graphify.extract <file_or_dir> ...", file=sys.stderr)
sys.exit(1)
paths: list[Path] = []
for arg in sys.argv[1:]:
paths.extend(collect_files(Path(arg)))
result = extract(paths)
print(json.dumps(result, indent=2))
+233
View File
@@ -0,0 +1,233 @@
# fetch URLs (tweet/arxiv/pdf/web) and save as annotated markdown
from __future__ import annotations
import json
import re
import sys
import urllib.request
import urllib.parse
from datetime import datetime, timezone
from pathlib import Path
def _safe_filename(url: str, suffix: str) -> str:
"""Turn a URL into a safe filename."""
parsed = urllib.parse.urlparse(url)
name = parsed.netloc + parsed.path
name = re.sub(r"[^\w\-]", "_", name).strip("_")
name = re.sub(r"_+", "_", name)[:80]
return name + suffix
def _detect_url_type(url: str) -> str:
"""Classify the URL for targeted extraction."""
lower = url.lower()
if "twitter.com" in lower or "x.com" in lower:
return "tweet"
if "arxiv.org" in lower:
return "arxiv"
if "github.com" in lower:
return "github"
if "youtube.com" in lower or "youtu.be" in lower:
return "youtube"
parsed = urllib.parse.urlparse(url)
path = parsed.path.lower()
if path.endswith(".pdf"):
return "pdf"
if any(path.endswith(ext) for ext in (".png", ".jpg", ".jpeg", ".webp", ".gif")):
return "image"
return "webpage"
def _fetch_html(url: str) -> str:
req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0 graphify/1.0"})
with urllib.request.urlopen(req, timeout=15) as resp:
return resp.read().decode("utf-8", errors="ignore")
def _html_to_markdown(html: str, url: str) -> str:
"""Convert HTML to clean markdown. Uses html2text if available, else basic strip."""
try:
import html2text
h = html2text.HTML2Text()
h.ignore_links = False
h.ignore_images = True
h.body_width = 0
return h.handle(html)
except ImportError:
# Fallback: strip tags
text = re.sub(r"<script[^>]*>.*?</script>", "", html, flags=re.DOTALL | re.IGNORECASE)
text = re.sub(r"<style[^>]*>.*?</style>", "", text, flags=re.DOTALL | re.IGNORECASE)
text = re.sub(r"<[^>]+>", " ", text)
text = re.sub(r"\s+", " ", text).strip()
return text[:8000]
def _fetch_tweet(url: str, author: str | None, contributor: str | None) -> tuple[str, str]:
"""Fetch a tweet URL. Returns (content, filename)."""
# Normalize to twitter.com for oEmbed
oembed_url = url.replace("x.com", "twitter.com")
oembed_api = f"https://publish.twitter.com/oembed?url={urllib.parse.quote(oembed_url)}&omit_script=true"
try:
req = urllib.request.Request(oembed_api, headers={"User-Agent": "graphify/1.0"})
with urllib.request.urlopen(req, timeout=10) as resp:
data = json.loads(resp.read())
tweet_text = re.sub(r"<[^>]+>", "", data.get("html", "")).strip()
tweet_author = data.get("author_name", "unknown")
except Exception:
# oEmbed failed — save URL stub
tweet_text = f"Tweet at {url} (could not fetch content)"
tweet_author = "unknown"
now = datetime.now(timezone.utc).isoformat()
content = f"""---
source_url: {url}
type: tweet
author: {tweet_author}
captured_at: {now}
contributor: {contributor or author or 'unknown'}
---
# Tweet by @{tweet_author}
{tweet_text}
Source: {url}
"""
filename = _safe_filename(url, ".md")
return content, filename
def _fetch_webpage(url: str, author: str | None, contributor: str | None) -> tuple[str, str]:
"""Fetch a generic webpage and convert to markdown."""
html = _fetch_html(url)
# Extract title
title_match = re.search(r"<title[^>]*>(.*?)</title>", html, re.IGNORECASE | re.DOTALL)
title = re.sub(r"\s+", " ", title_match.group(1)).strip() if title_match else url
markdown = _html_to_markdown(html, url)
now = datetime.now(timezone.utc).isoformat()
content = f"""---
source_url: {url}
type: webpage
title: "{title}"
captured_at: {now}
contributor: {contributor or author or 'unknown'}
---
# {title}
Source: {url}
---
{markdown[:12000]}
"""
filename = _safe_filename(url, ".md")
return content, filename
def _fetch_arxiv(url: str, author: str | None, contributor: str | None) -> tuple[str, str]:
"""Fetch arXiv abstract page."""
# Convert /abs/ or /pdf/ to abs for the API
arxiv_id = re.search(r"(\d{4}\.\d{4,5})", url)
if arxiv_id:
api_url = f"https://export.arxiv.org/abs/{arxiv_id.group(1)}"
try:
html = _fetch_html(api_url)
abstract_match = re.search(r'class="abstract[^"]*"[^>]*>(.*?)</blockquote>', html, re.DOTALL | re.IGNORECASE)
abstract = re.sub(r"<[^>]+>", "", abstract_match.group(1)).strip() if abstract_match else ""
title_match = re.search(r'class="title[^"]*"[^>]*>(.*?)</h1>', html, re.DOTALL | re.IGNORECASE)
title = re.sub(r"<[^>]+>", " ", title_match.group(1)).strip() if title_match else arxiv_id.group(1)
authors_match = re.search(r'class="authors"[^>]*>(.*?)</div>', html, re.DOTALL | re.IGNORECASE)
paper_authors = re.sub(r"<[^>]+>", "", authors_match.group(1)).strip() if authors_match else ""
except Exception:
title, abstract, paper_authors = arxiv_id.group(1), "", ""
else:
return _fetch_webpage(url, author, contributor)
now = datetime.now(timezone.utc).isoformat()
content = f"""---
source_url: {url}
arxiv_id: {arxiv_id.group(1) if arxiv_id else ''}
type: paper
title: "{title}"
paper_authors: "{paper_authors}"
captured_at: {now}
contributor: {contributor or author or 'unknown'}
---
# {title}
**Authors:** {paper_authors}
**arXiv:** {arxiv_id.group(1) if arxiv_id else url}
## Abstract
{abstract}
Source: {url}
"""
filename = f"arxiv_{arxiv_id.group(1).replace('.', '_')}.md" if arxiv_id else _safe_filename(url, ".md")
return content, filename
def _download_binary(url: str, suffix: str, target_dir: Path) -> Path:
"""Download a binary file (PDF, image) directly."""
filename = _safe_filename(url, suffix)
out_path = target_dir / filename
req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0 graphify/1.0"})
with urllib.request.urlopen(req, timeout=30) as resp:
out_path.write_bytes(resp.read())
return out_path
def ingest(url: str, target_dir: Path, author: str | None = None, contributor: str | None = None) -> Path:
"""
Fetch a URL and save it into target_dir as a graphify-ready file.
Returns the path of the saved file.
"""
target_dir.mkdir(parents=True, exist_ok=True)
url_type = _detect_url_type(url)
if url_type == "pdf":
out = _download_binary(url, ".pdf", target_dir)
print(f"Downloaded PDF: {out.name}")
return out
if url_type == "image":
suffix = Path(urllib.parse.urlparse(url).path).suffix or ".jpg"
out = _download_binary(url, suffix, target_dir)
print(f"Downloaded image: {out.name}")
return out
if url_type == "tweet":
content, filename = _fetch_tweet(url, author, contributor)
elif url_type == "arxiv":
content, filename = _fetch_arxiv(url, author, contributor)
else:
content, filename = _fetch_webpage(url, author, contributor)
out_path = target_dir / filename
# Avoid overwriting — append counter if needed
counter = 1
while out_path.exists():
stem = Path(filename).stem
out_path = target_dir / f"{stem}_{counter}.md"
counter += 1
out_path.write_text(content, encoding="utf-8")
print(f"Saved {url_type}: {out_path.name}")
return out_path
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(description="Fetch a URL into a graphify /raw folder")
parser.add_argument("url", help="URL to fetch")
parser.add_argument("target_dir", nargs="?", default="./raw", help="Target directory (default: ./raw)")
parser.add_argument("--author", help="Your name (stored as node metadata)")
parser.add_argument("--contributor", help="Contributor name for team graphs")
args = parser.parse_args()
out = ingest(args.url, Path(args.target_dir), author=args.author, contributor=args.contributor)
print(f"Ready for graphify: {out}")
@@ -1,3 +1,4 @@
# generate GRAPH_REPORT.md — the human-readable audit trail
from __future__ import annotations
from datetime import date
import networkx as nx
@@ -13,6 +14,7 @@ def generate(
detection_result: dict,
token_cost: dict,
root: str,
suggested_questions: list[dict] | None = None,
) -> str:
today = date.today().isoformat()
@@ -50,24 +52,30 @@ def generate(
lines += ["", "## Surprising Connections (you probably didn't know these)"]
if surprise_list:
for s in surprise_list:
relation = s.get("relation", "related_to")
note = s.get("note", "")
files = s.get("source_files", ["", ""])
lines += [
f"- `{s['source']}` `{s['target']}` [{s['confidence']}]",
f" {s['source_files'][0]} {s['source_files'][1]}",
f"- `{s['source']}` --{relation}--> `{s['target']}` [{s['confidence']}]",
f" {files[0]} {files[1]}" + (f" _{note}_" if note else ""),
]
else:
lines.append("- None detected — all connections are within the same source files.")
lines += ["", "## Communities"]
from .analyze import _is_file_node as _ifn
for cid, nodes in communities.items():
label = community_labels.get(cid, f"Community {cid}")
score = cohesion_scores.get(cid, 0.0)
node_labels = [G.nodes[n].get("label", n) for n in nodes[:8]]
suffix = "..." if len(nodes) > 8 else ""
# Filter method/function stubs from display — they're structural noise
real_nodes = [n for n in nodes if not _ifn(G, n)]
display = [G.nodes[n].get("label", n) for n in real_nodes[:8]]
suffix = f" (+{len(real_nodes)-8} more)" if len(real_nodes) > 8 else ""
lines += [
"",
f"### Community {cid}\"{label}\"",
f"Cohesion: {score}",
f"Nodes ({len(nodes)}): {', '.join(node_labels)}{suffix}",
f"Nodes ({len(real_nodes)}): {', '.join(display)}{suffix}",
]
ambiguous = [(u, v, d) for u, v, d in G.edges(data=True) if d.get("confidence") == "AMBIGUOUS"]
@@ -81,4 +89,40 @@ def generate(
f" {d.get('source_file', '')} · relation: {d.get('relation', 'unknown')}",
]
# --- Gaps section ---
from .analyze import _is_file_node, _is_concept_node
isolated = [
n for n in G.nodes()
if G.degree(n) <= 1 and not _is_file_node(G, n) and not _is_concept_node(G, n)
]
thin_communities = {
cid: nodes for cid, nodes in communities.items() if len(nodes) < 3
}
gap_count = len(isolated) + len(thin_communities)
if gap_count > 0 or amb_pct > 20:
lines += ["", "## Knowledge Gaps"]
if isolated:
isolated_labels = [G.nodes[n].get("label", n) for n in isolated[:5]]
suffix = f" (+{len(isolated)-5} more)" if len(isolated) > 5 else ""
lines.append(f"- **{len(isolated)} isolated node(s):** {', '.join(f'`{l}`' for l in isolated_labels)}{suffix}")
lines.append(" These have ≤1 connection — possible missing edges or undocumented components.")
if thin_communities:
for cid, nodes in thin_communities.items():
label = community_labels.get(cid, f"Community {cid}")
node_labels = [G.nodes[n].get("label", n) for n in nodes]
lines.append(f"- **Thin community `{label}`** ({len(nodes)} nodes): {', '.join(f'`{l}`' for l in node_labels)}")
lines.append(" Too small to be a meaningful cluster — may be noise or needs more connections extracted.")
if amb_pct > 20:
lines.append(f"- **High ambiguity: {amb_pct}% of edges are AMBIGUOUS.** Review the Ambiguous Edges section above.")
if suggested_questions:
lines += ["", "## Suggested Questions"]
lines.append("_Questions this graph is uniquely positioned to answer:_")
lines.append("")
for q in suggested_questions:
lines.append(f"- **{q['question']}**")
lines.append(f" _{q['why']}_")
return "\n".join(lines)
+270
View File
@@ -0,0 +1,270 @@
# MCP stdio server — exposes graph query tools to Claude and other agents
from __future__ import annotations
import json
import sys
from pathlib import Path
import networkx as nx
from networkx.readwrite import json_graph
def _load_graph(graph_path: str) -> nx.Graph:
data = json.loads(Path(graph_path).read_text())
return json_graph.node_link_graph(data, edges="links")
def _communities_from_graph(G: nx.Graph) -> dict[int, list[str]]:
"""Reconstruct community dict from community property stored on nodes."""
communities: dict[int, list[str]] = {}
for node_id, data in G.nodes(data=True):
cid = data.get("community")
if cid is not None:
communities.setdefault(int(cid), []).append(node_id)
return communities
def _score_nodes(G: nx.Graph, terms: list[str]) -> list[tuple[float, str]]:
scored = []
for nid, data in G.nodes(data=True):
label = data.get("label", "").lower()
source = data.get("source_file", "").lower()
score = sum(1 for t in terms if t in label) + sum(0.5 for t in terms if t in source)
if score > 0:
scored.append((score, nid))
return sorted(scored, reverse=True)
def _bfs(G: nx.Graph, start_nodes: list[str], depth: int) -> tuple[set[str], list[tuple]]:
visited: set[str] = set(start_nodes)
frontier = set(start_nodes)
edges_seen: list[tuple] = []
for _ in range(depth):
next_frontier: set[str] = set()
for n in frontier:
for neighbor in G.neighbors(n):
if neighbor not in visited:
next_frontier.add(neighbor)
edges_seen.append((n, neighbor))
visited.update(next_frontier)
frontier = next_frontier
return visited, edges_seen
def _dfs(G: nx.Graph, start_nodes: list[str], depth: int) -> tuple[set[str], list[tuple]]:
visited: set[str] = set()
edges_seen: list[tuple] = []
stack = [(n, 0) for n in reversed(start_nodes)]
while stack:
node, d = stack.pop()
if node in visited or d > depth:
continue
visited.add(node)
for neighbor in G.neighbors(node):
if neighbor not in visited:
stack.append((neighbor, d + 1))
edges_seen.append((node, neighbor))
return visited, edges_seen
def _subgraph_to_text(G: nx.Graph, nodes: set[str], edges: list[tuple], token_budget: int = 2000) -> str:
"""Render subgraph as text, cutting at token_budget (approx 4 chars/token)."""
char_budget = token_budget * 4
lines = []
for nid in sorted(nodes, key=lambda n: G.degree(n), reverse=True):
d = G.nodes[nid]
line = f"NODE {d.get('label', nid)} [src={d.get('source_file', '')} loc={d.get('source_location', '')} community={d.get('community', '')}]"
lines.append(line)
for u, v in edges:
if u in nodes and v in nodes:
d = G.edges[u, v]
line = f"EDGE {G.nodes[u].get('label', u)} --{d.get('relation', '')} [{d.get('confidence', '')}]--> {G.nodes[v].get('label', v)}"
lines.append(line)
output = "\n".join(lines)
if len(output) > char_budget:
output = output[:char_budget] + f"\n... (truncated to ~{token_budget} token budget)"
return output
def serve(graph_path: str = ".graphify/graph.json") -> None:
"""Start the MCP server. Requires pip install mcp."""
try:
from mcp.server import Server
from mcp.server.stdio import stdio_server
from mcp import types
except ImportError as e:
raise ImportError("mcp not installed. Run: pip install mcp") from e
G = _load_graph(graph_path)
communities = _communities_from_graph(G)
server = Server("graphify")
@server.list_tools()
async def list_tools() -> list[types.Tool]:
return [
types.Tool(
name="query_graph",
description="Search the knowledge graph using BFS or DFS. Returns relevant nodes and edges as text context.",
inputSchema={
"type": "object",
"properties": {
"question": {"type": "string", "description": "Natural language question or keyword search"},
"mode": {"type": "string", "enum": ["bfs", "dfs"], "default": "bfs",
"description": "bfs=broad context, dfs=trace a specific path"},
"depth": {"type": "integer", "default": 3, "description": "Traversal depth (1-6)"},
"token_budget": {"type": "integer", "default": 2000, "description": "Max output tokens"},
},
"required": ["question"],
},
),
types.Tool(
name="get_node",
description="Get full details for a specific node by label or ID.",
inputSchema={
"type": "object",
"properties": {
"label": {"type": "string", "description": "Node label or ID to look up"},
},
"required": ["label"],
},
),
types.Tool(
name="get_neighbors",
description="Get all direct neighbors of a node with edge details.",
inputSchema={
"type": "object",
"properties": {
"label": {"type": "string"},
"relation_filter": {"type": "string", "description": "Optional: filter by relation type"},
},
"required": ["label"],
},
),
types.Tool(
name="get_community",
description="Get all nodes in a community by community ID or label.",
inputSchema={
"type": "object",
"properties": {
"community_id": {"type": "integer", "description": "Community ID (0-indexed by size)"},
},
"required": ["community_id"],
},
),
types.Tool(
name="god_nodes",
description="Return the most connected nodes — the core abstractions of the knowledge graph.",
inputSchema={
"type": "object",
"properties": {
"top_n": {"type": "integer", "default": 10},
},
},
),
types.Tool(
name="graph_stats",
description="Return summary statistics: node count, edge count, communities, confidence breakdown.",
inputSchema={"type": "object", "properties": {}},
),
]
@server.call_tool()
async def call_tool(name: str, arguments: dict) -> list[types.TextContent]:
if name == "query_graph":
question = arguments["question"]
mode = arguments.get("mode", "bfs")
depth = min(int(arguments.get("depth", 3)), 6)
budget = int(arguments.get("token_budget", 2000))
terms = [t.lower() for t in question.split() if len(t) > 2]
scored = _score_nodes(G, terms)
start_nodes = [nid for _, nid in scored[:3]]
if not start_nodes:
return [types.TextContent(type="text", text="No matching nodes found.")]
if mode == "dfs":
nodes, edges = _dfs(G, start_nodes, depth)
else:
nodes, edges = _bfs(G, start_nodes, depth)
text = f"Traversal: {mode.upper()} depth={depth} | Start: {[G.nodes[n].get('label', n) for n in start_nodes]} | {len(nodes)} nodes found\n\n"
text += _subgraph_to_text(G, nodes, edges, budget)
return [types.TextContent(type="text", text=text)]
elif name == "get_node":
label = arguments["label"].lower()
matches = [(nid, d) for nid, d in G.nodes(data=True)
if label in d.get("label", "").lower() or label == nid.lower()]
if not matches:
return [types.TextContent(type="text", text=f"No node matching '{label}' found.")]
nid, d = matches[0]
lines = [f"Node: {d.get('label', nid)}",
f" ID: {nid}",
f" Source: {d.get('source_file', '')} {d.get('source_location', '')}",
f" Type: {d.get('file_type', '')}",
f" Community: {d.get('community', '')}",
f" Degree: {G.degree(nid)}"]
return [types.TextContent(type="text", text="\n".join(lines))]
elif name == "get_neighbors":
label = arguments["label"].lower()
rel_filter = arguments.get("relation_filter", "").lower()
matches = [nid for nid, d in G.nodes(data=True)
if label in d.get("label", "").lower() or label == nid.lower()]
if not matches:
return [types.TextContent(type="text", text=f"No node matching '{label}' found.")]
nid = matches[0]
lines = [f"Neighbors of {G.nodes[nid].get('label', nid)}:"]
for neighbor in G.neighbors(nid):
d = G.edges[nid, neighbor]
rel = d.get("relation", "")
if rel_filter and rel_filter not in rel.lower():
continue
conf = d.get("confidence", "")
nlabel = G.nodes[neighbor].get("label", neighbor)
lines.append(f" --> {nlabel} [{rel}] [{conf}]")
return [types.TextContent(type="text", text="\n".join(lines))]
elif name == "get_community":
cid = int(arguments["community_id"])
nodes = communities.get(cid, [])
if not nodes:
return [types.TextContent(type="text", text=f"Community {cid} not found.")]
lines = [f"Community {cid} ({len(nodes)} nodes):"]
for n in nodes:
d = G.nodes[n]
lines.append(f" {d.get('label', n)} [{d.get('source_file', '')}]")
return [types.TextContent(type="text", text="\n".join(lines))]
elif name == "god_nodes":
from .analyze import god_nodes as _god_nodes
top_n = int(arguments.get("top_n", 10))
nodes = _god_nodes(G, top_n=top_n)
lines = ["God nodes (most connected):"]
for i, n in enumerate(nodes, 1):
lines.append(f" {i}. {n['label']}{n['edges']} edges")
return [types.TextContent(type="text", text="\n".join(lines))]
elif name == "graph_stats":
confs = [d.get("confidence", "EXTRACTED") for _, _, d in G.edges(data=True)]
total = len(confs) or 1
text = (
f"Nodes: {G.number_of_nodes()}\n"
f"Edges: {G.number_of_edges()}\n"
f"Communities: {len(communities)}\n"
f"EXTRACTED: {round(confs.count('EXTRACTED')/total*100)}%\n"
f"INFERRED: {round(confs.count('INFERRED')/total*100)}%\n"
f"AMBIGUOUS: {round(confs.count('AMBIGUOUS')/total*100)}%\n"
)
return [types.TextContent(type="text", text=text)]
return [types.TextContent(type="text", text=f"Unknown tool: {name}")]
import asyncio
async def main() -> None:
async with stdio_server() as streams:
await server.run(streams[0], streams[1], server.create_initialization_options())
asyncio.run(main())
if __name__ == "__main__":
graph_path = sys.argv[1] if len(sys.argv) > 1 else ".graphify/graph.json"
serve(graph_path)
@@ -1,3 +1,4 @@
# validate extraction JSON against the graphify schema before graph assembly
from __future__ import annotations
VALID_FILE_TYPES = {"code", "document", "paper"}
+81
View File
@@ -0,0 +1,81 @@
# monitor a folder and auto-trigger --update when files change
from __future__ import annotations
import time
from pathlib import Path
_WATCHED_EXTENSIONS = {
".py", ".ts", ".js", ".go", ".rs", ".java", ".cpp", ".c", ".rb", ".swift", ".kt",
".md", ".txt", ".rst", ".pdf",
".png", ".jpg", ".jpeg", ".webp", ".gif", ".svg",
}
def _run_update(watch_path: Path) -> None:
"""Write a flag file and print a notification when files change."""
flag = watch_path / ".graphify" / "needs_update"
flag.parent.mkdir(parents=True, exist_ok=True)
flag.write_text("1")
print(f"\n[graphify watch] New or changed files detected in {watch_path}")
print("[graphify watch] Run `/graphify --update` in Claude Code to update the graph.")
print(f"[graphify watch] Flag written to {flag}")
def watch(watch_path: Path, debounce: float = 3.0) -> None:
"""
Watch watch_path for new or modified files and re-run graphify --update.
debounce: seconds to wait after the last change before triggering (avoids
running on every keystroke when many files are saved at once).
"""
try:
from watchdog.observers import Observer
from watchdog.events import FileSystemEventHandler
except ImportError as e:
raise ImportError("watchdog not installed. Run: pip install watchdog") from e
last_trigger: float = 0.0
pending: bool = False
class Handler(FileSystemEventHandler):
def on_any_event(self, event):
nonlocal last_trigger, pending
if event.is_directory:
return
path = Path(event.src_path)
if path.suffix.lower() not in _WATCHED_EXTENSIONS:
return
if any(part.startswith(".") for part in path.parts):
return
last_trigger = time.monotonic()
pending = True
handler = Handler()
observer = Observer()
observer.schedule(handler, str(watch_path), recursive=True)
observer.start()
print(f"[graphify watch] Watching {watch_path.resolve()} — press Ctrl+C to stop")
print(f"[graphify watch] Debounce: {debounce}s — will update {debounce}s after last change")
try:
while True:
time.sleep(0.5)
if pending and (time.monotonic() - last_trigger) >= debounce:
pending = False
_run_update(watch_path)
except KeyboardInterrupt:
print("\n[graphify watch] Stopped.")
finally:
observer.stop()
observer.join()
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(description="Watch a folder and auto-update the graphify graph")
parser.add_argument("path", nargs="?", default=".", help="Folder to watch (default: .)")
parser.add_argument("--debounce", type=float, default=3.0,
help="Seconds to wait after last change before updating (default: 3)")
args = parser.parse_args()
watch(Path(args.path), debounce=args.debounce)
+19
View File
@@ -0,0 +1,19 @@
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
[project]
name = "graphify"
version = "0.1.1"
requires-python = ">=3.10"
dependencies = [
"networkx",
"graspologic",
"pyvis",
"tree-sitter",
"tree-sitter-python",
]
[tool.setuptools.packages.find]
where = ["."]
include = ["graphify*"]
-10
View File
@@ -1,10 +0,0 @@
networkx>=3.3
graspologic>=3.3
pyvis>=0.3.2
tree-sitter>=0.23.0
tree-sitter-python>=0.23.0
tree-sitter-javascript>=0.23.0
tree-sitter-typescript>=0.23.2
pytest>=8.0
pytest-cov>=5.0
pypdf>=4.0
-2
View File
@@ -1,2 +0,0 @@
from setuptools import setup, find_packages
setup(name="graphify", packages=find_packages(where="src"), package_dir={"": "src"})
+874 -112
View File
File diff suppressed because it is too large Load Diff
-1
View File
@@ -1 +0,0 @@
"""graphify — any input → knowledge graph → clustered communities → audit report."""
-158
View File
@@ -1,158 +0,0 @@
from __future__ import annotations
import networkx as nx
def god_nodes(G: nx.Graph, top_n: int = 10) -> list[dict]:
"""Return the top_n most-connected nodes — the core abstractions."""
degree = dict(G.degree())
sorted_nodes = sorted(degree.items(), key=lambda x: x[1], reverse=True)[:top_n]
return [
{
"id": node_id,
"label": G.nodes[node_id].get("label", node_id),
"edges": deg,
}
for node_id, deg in sorted_nodes
]
def surprising_connections(
G: nx.Graph,
communities: dict[int, list[str]] | None = None,
top_n: int = 5,
) -> list[dict]:
"""
Find connections that are genuinely surprising not obvious from file structure.
Strategy:
- Multi-file corpora: cross-file edges between real entities (not concept nodes).
Sorted AMBIGUOUS INFERRED EXTRACTED.
- Single-file / single-source corpora: cross-community edges that bridge
distant parts of the graph (betweenness centrality on edges).
These reveal non-obvious structural couplings.
Concept nodes (empty source_file, or injected semantic annotations) are excluded
from surprising connections because they are intentional, not discovered.
"""
# Identify unique source files (ignore empty/null source_file)
source_files = {
data.get("source_file", "")
for _, data in G.nodes(data=True)
if data.get("source_file", "")
}
is_multi_source = len(source_files) > 1
if is_multi_source:
return _cross_file_surprises(G, top_n)
else:
return _cross_community_surprises(G, communities or {}, top_n)
def _is_concept_node(G: nx.Graph, node_id: str) -> bool:
"""
Return True if this node is a manually-injected semantic concept node
rather than a real entity found in source code.
Signals:
- Empty source_file
- source_file doesn't look like a real file path (no extension)
"""
data = G.nodes[node_id]
source = data.get("source_file", "")
if not source:
return True
# Has no file extension → probably a concept label, not a real file
if "." not in source.split("/")[-1]:
return True
return False
def _cross_file_surprises(G: nx.Graph, top_n: int) -> list[dict]:
"""
Cross-file edges between real code/doc entities.
Excludes concept nodes. Sorted AMBIGUOUS first.
"""
surprises = []
order = {"AMBIGUOUS": 0, "INFERRED": 1, "EXTRACTED": 2}
for u, v, data in G.edges(data=True):
# Skip if either endpoint is a concept node
if _is_concept_node(G, u) or _is_concept_node(G, v):
continue
u_source = G.nodes[u].get("source_file", "")
v_source = G.nodes[v].get("source_file", "")
if u_source and v_source and u_source != v_source:
surprises.append({
"source": G.nodes[u].get("label", u),
"target": G.nodes[v].get("label", v),
"source_files": [u_source, v_source],
"confidence": data.get("confidence", "EXTRACTED"),
"relation": data.get("relation", ""),
})
surprises.sort(key=lambda x: order.get(x["confidence"], 3))
return surprises[:top_n]
def _cross_community_surprises(
G: nx.Graph,
communities: dict[int, list[str]],
top_n: int,
) -> list[dict]:
"""
For single-source corpora: find edges that bridge different communities.
These are surprising because Leiden grouped everything else tightly
these edges cut across the natural structure.
Falls back to high-betweenness edges if no community info is provided.
"""
if not communities:
# No community info — use edge betweenness centrality
if G.number_of_edges() == 0:
return []
betweenness = nx.edge_betweenness_centrality(G)
top_edges = sorted(betweenness.items(), key=lambda x: x[1], reverse=True)[:top_n]
result = []
for (u, v), score in top_edges:
data = G.edges[u, v]
result.append({
"source": G.nodes[u].get("label", u),
"target": G.nodes[v].get("label", v),
"source_files": [
G.nodes[u].get("source_file", ""),
G.nodes[v].get("source_file", ""),
],
"confidence": data.get("confidence", "EXTRACTED"),
"relation": data.get("relation", ""),
"note": f"Bridges graph structure (betweenness={score:.3f})",
})
return result
# Build node → community map
node_community = {n: cid for cid, nodes in communities.items() for n in nodes}
surprises = []
for u, v, data in G.edges(data=True):
cid_u = node_community.get(u)
cid_v = node_community.get(v)
if cid_u is not None and cid_v is not None and cid_u != cid_v:
# This edge crosses community boundaries — interesting
confidence = data.get("confidence", "EXTRACTED")
surprises.append({
"source": G.nodes[u].get("label", u),
"target": G.nodes[v].get("label", v),
"source_files": [
G.nodes[u].get("source_file", ""),
G.nodes[v].get("source_file", ""),
],
"confidence": confidence,
"relation": data.get("relation", ""),
"note": f"Bridges community {cid_u} → community {cid_v}",
})
# Sort: AMBIGUOUS first, then INFERRED, then EXTRACTED
order = {"AMBIGUOUS": 0, "INFERRED": 1, "EXTRACTED": 2}
surprises.sort(key=lambda x: order.get(x["confidence"], 3))
return surprises[:top_n]
-190
View File
@@ -1,190 +0,0 @@
"""
Deterministic structural extraction from Python code using tree-sitter.
Outputs JSON nodes+edges compatible with the graphify extraction schema.
Usage:
python -m graphify.ast_extractor file1.py [file2.py ...]
python -m graphify.ast_extractor ./src/
"""
from __future__ import annotations
import json
import re
import sys
from pathlib import Path
def _make_id(*parts: str) -> str:
"""Build a stable node ID from one or more name parts."""
combined = "_".join(p.strip("_.") for p in parts if p)
cleaned = re.sub(r"[^a-zA-Z0-9]+", "_", combined)
return cleaned.strip("_").lower()
def extract_python(path: Path) -> dict:
"""Extract classes, functions, and imports from a .py file via tree-sitter AST."""
try:
import tree_sitter_python as tspython
from tree_sitter import Language, Parser
except ImportError:
return {"nodes": [], "edges": [], "error": "tree-sitter-python not installed"}
try:
language = Language(tspython.language())
parser = Parser(language)
source = path.read_bytes()
tree = parser.parse(source)
root = tree.root_node
except Exception as e:
return {"nodes": [], "edges": [], "error": str(e)}
stem = path.stem
str_path = str(path)
nodes: list[dict] = []
edges: list[dict] = []
seen_ids: set[str] = set()
def add_node(nid: str, label: str, line: int) -> None:
if nid not in seen_ids:
seen_ids.add(nid)
nodes.append({
"id": nid,
"label": label,
"file_type": "code",
"source_file": str_path,
"source_location": f"L{line}",
})
def add_edge(src: str, tgt: str, relation: str, line: int) -> None:
# Only add edge if both endpoints exist or src is the file node
edges.append({
"source": src,
"target": tgt,
"relation": relation,
"confidence": "EXTRACTED",
"source_file": str_path,
"source_location": f"L{line}",
"weight": 1.0,
})
# File-level node — stable ID based on stem only
file_nid = _make_id(stem)
add_node(file_nid, path.name, 1)
def walk(node, parent_class_nid: str | None = None) -> None:
t = node.type
if t == "import_statement":
for child in node.children:
if child.type in ("dotted_name", "aliased_import"):
raw = source[child.start_byte:child.end_byte].decode()
module_name = raw.split(" as ")[0].strip().lstrip(".")
tgt_nid = _make_id(module_name)
add_edge(file_nid, tgt_nid, "imports", node.start_point[0] + 1)
return
if t == "import_from_statement":
module_node = node.child_by_field_name("module_name")
if module_node:
raw = source[module_node.start_byte:module_node.end_byte].decode().lstrip(".")
tgt_nid = _make_id(raw)
add_edge(file_nid, tgt_nid, "imports_from", node.start_point[0] + 1)
return
if t == "class_definition":
name_node = node.child_by_field_name("name")
if not name_node:
return
class_name = source[name_node.start_byte:name_node.end_byte].decode()
class_nid = _make_id(stem, class_name)
line = node.start_point[0] + 1
add_node(class_nid, class_name, line)
add_edge(file_nid, class_nid, "contains", line)
# Inheritance
args = node.child_by_field_name("superclasses")
if args:
for arg in args.children:
if arg.type == "identifier":
base = source[arg.start_byte:arg.end_byte].decode()
base_nid = _make_id(stem, base)
add_edge(class_nid, base_nid, "inherits", line)
# Walk class body for methods
body = node.child_by_field_name("body")
if body:
for child in body.children:
walk(child, parent_class_nid=class_nid)
return
if t == "function_definition":
name_node = node.child_by_field_name("name")
if not name_node:
return
func_name = source[name_node.start_byte:name_node.end_byte].decode()
line = node.start_point[0] + 1
if parent_class_nid:
func_nid = _make_id(parent_class_nid, func_name)
add_node(func_nid, f".{func_name}()", line)
add_edge(parent_class_nid, func_nid, "method", line)
else:
func_nid = _make_id(stem, func_name)
add_node(func_nid, f"{func_name}()", line)
add_edge(file_nid, func_nid, "contains", line)
return
for child in node.children:
walk(child, parent_class_nid=None)
walk(root)
# Post-process: remove edges whose source or target was never added as a node
# (dangling import edges pointing to external libraries are fine to keep,
# but edges between internal entities must be valid)
valid_ids = seen_ids
clean_edges = []
for edge in edges:
src, tgt = edge["source"], edge["target"]
# Keep if both endpoints are known, OR if it's an import edge (tgt may be external)
if src in valid_ids and (tgt in valid_ids or edge["relation"] in ("imports", "imports_from")):
clean_edges.append(edge)
return {"nodes": nodes, "edges": clean_edges}
def extract(paths: list[Path]) -> dict:
"""Extract AST nodes and edges from a list of code files."""
all_nodes: list[dict] = []
all_edges: list[dict] = []
for path in paths:
if path.suffix == ".py":
result = extract_python(path)
all_nodes.extend(result.get("nodes", []))
all_edges.extend(result.get("edges", []))
return {
"nodes": all_nodes,
"edges": all_edges,
"input_tokens": 0,
"output_tokens": 0,
}
def collect_files(target: Path) -> list[Path]:
if target.is_file():
return [target]
return sorted(p for p in target.rglob("*.py")
if not any(part.startswith(".") for part in p.parts))
if __name__ == "__main__":
if len(sys.argv) < 2:
print("Usage: python -m graphify.ast_extractor <file_or_dir> ...", file=sys.stderr)
sys.exit(1)
paths: list[Path] = []
for arg in sys.argv[1:]:
paths.extend(collect_files(Path(arg)))
result = extract(paths)
print(json.dumps(result, indent=2))
-108
View File
@@ -1,108 +0,0 @@
from __future__ import annotations
import re
from pathlib import Path
from .models import FileType
CODE_EXTENSIONS = {'.py', '.ts', '.js', '.go', '.rs', '.java', '.cpp', '.c', '.rb', '.swift', '.kt'}
DOC_EXTENSIONS = {'.md', '.txt', '.rst'}
PAPER_EXTENSIONS = {'.pdf'}
CORPUS_WARN_THRESHOLD = 50_000 # words
# Signals that a .md/.txt file is actually a converted academic paper
_PAPER_SIGNALS = [
re.compile(r'\barxiv\b', re.IGNORECASE),
re.compile(r'\bdoi\s*:', re.IGNORECASE),
re.compile(r'\babstract\b', re.IGNORECASE),
re.compile(r'\bproceedings\b', re.IGNORECASE),
re.compile(r'\bjournal\b', re.IGNORECASE),
re.compile(r'\bpreprint\b', re.IGNORECASE),
re.compile(r'\\cite\{'), # LaTeX citation
re.compile(r'\[\d+\]'), # Numbered citation [1], [23] (inline)
re.compile(r'\[\n\d+\n\]'), # Numbered citation spread across lines (markdown conversion)
re.compile(r'eq\.\s*\d+|equation\s+\d+', re.IGNORECASE),
re.compile(r'\d{4}\.\d{4,5}'), # arXiv ID like 1706.03762
re.compile(r'\bwe propose\b', re.IGNORECASE), # common academic phrasing
re.compile(r'\bliterature\b', re.IGNORECASE), # "from the literature"
]
_PAPER_SIGNAL_THRESHOLD = 3 # need at least this many signals to call it a paper
def _looks_like_paper(path: Path) -> bool:
"""Heuristic: does this text file read like an academic paper?"""
try:
# Only scan first 3000 chars for speed
text = path.read_text(errors="ignore")[:3000]
hits = sum(1 for pattern in _PAPER_SIGNALS if pattern.search(text))
return hits >= _PAPER_SIGNAL_THRESHOLD
except Exception:
return False
def classify_file(path: Path) -> FileType | None:
ext = path.suffix.lower()
if ext in CODE_EXTENSIONS:
return FileType.CODE
if ext in PAPER_EXTENSIONS:
return FileType.PAPER
if ext in DOC_EXTENSIONS:
# Check if it's a converted paper
if _looks_like_paper(path):
return FileType.PAPER
return FileType.DOCUMENT
return None
def extract_pdf_text(path: Path) -> str:
"""Extract plain text from a PDF file using pypdf."""
try:
from pypdf import PdfReader
reader = PdfReader(str(path))
pages = []
for page in reader.pages:
text = page.extract_text()
if text:
pages.append(text)
return "\n".join(pages)
except Exception:
return ""
def count_words(path: Path) -> int:
try:
if path.suffix.lower() == ".pdf":
return len(extract_pdf_text(path).split())
return len(path.read_text(errors="ignore").split())
except Exception:
return 0
def detect(root: Path) -> dict:
files: dict[FileType, list[str]] = {
FileType.CODE: [],
FileType.DOCUMENT: [],
FileType.PAPER: [],
}
total_words = 0
for p in sorted(root.rglob("*")):
if not p.is_file():
continue
if any(part.startswith(".") for part in p.relative_to(root).parts):
continue
ftype = classify_file(p)
if ftype:
files[ftype].append(str(p))
total_words += count_words(p)
needs_graph = total_words >= CORPUS_WARN_THRESHOLD
return {
"files": {k.value: v for k, v in files.items()},
"total_files": sum(len(v) for v in files.values()),
"total_words": total_words,
"needs_graph": needs_graph,
"warning": None if needs_graph else (
f"Corpus is ~{total_words:,} words — fits in a single context window. "
f"You may not need a graph."
),
}
-31
View File
@@ -1,31 +0,0 @@
from __future__ import annotations
import json
import networkx as nx
from networkx.readwrite import json_graph
def to_json(G: nx.Graph, communities: dict[int, list[str]], output_path: str) -> None:
node_community = {n: cid for cid, nodes in communities.items() for n in nodes}
data = json_graph.node_link_data(G, edges="links")
for node in data["nodes"]:
node["community"] = node_community.get(node["id"])
with open(output_path, "w") as f:
json.dump(data, f, indent=2)
def to_cypher(G: nx.Graph, output_path: str) -> None:
lines = ["// Neo4j Cypher import — generated by /graphify", ""]
for node_id, data in G.nodes(data=True):
label = data.get("label", node_id).replace("'", "\\'")
ftype = data.get("file_type", "unknown").capitalize()
lines.append(f"MERGE (n:{ftype} {{id: '{node_id}', label: '{label}'}});")
lines.append("")
for u, v, data in G.edges(data=True):
rel = data.get("relation", "RELATES_TO").upper().replace(" ", "_").replace("-", "_")
conf = data.get("confidence", "EXTRACTED")
lines.append(
f"MATCH (a {{id: '{u}'}}), (b {{id: '{v}'}}) "
f"MERGE (a)-[:{rel} {{confidence: '{conf}'}}]->(b);"
)
with open(output_path, "w") as f:
f.write("\n".join(lines))
-45
View File
@@ -1,45 +0,0 @@
from __future__ import annotations
from dataclasses import dataclass, field
from enum import Enum
from typing import Optional
class Confidence(str, Enum):
EXTRACTED = "EXTRACTED"
INFERRED = "INFERRED"
AMBIGUOUS = "AMBIGUOUS"
class FileType(str, Enum):
CODE = "code"
DOCUMENT = "document"
PAPER = "paper"
@dataclass
class GraphNode:
id: str
label: str
file_type: FileType
source_file: str
source_location: Optional[str] = None
community: Optional[int] = None
@dataclass
class GraphEdge:
source: str
target: str
relation: str
confidence: Confidence
source_file: str
source_location: Optional[str] = None
weight: float = 1.0
@dataclass
class ExtractionResult:
nodes: list[GraphNode] = field(default_factory=list)
edges: list[GraphEdge] = field(default_factory=list)
input_tokens: int = 0
output_tokens: int = 0
-79
View File
@@ -1,79 +0,0 @@
from __future__ import annotations
from pathlib import Path
import networkx as nx
from pyvis.network import Network
COMMUNITY_COLORS = [
"#4E79A7", "#F28E2B", "#E15759", "#76B7B2", "#59A14F",
"#EDC948", "#B07AA1", "#FF9DA7", "#9C755F", "#BAB0AC",
]
MAX_NODES_FOR_VIZ = 5_000
def generate_html(
G: nx.Graph,
communities: dict[int, list[str]],
output_path: str,
community_labels: dict[int, str] | None = None,
) -> None:
if G.number_of_nodes() > MAX_NODES_FOR_VIZ:
raise ValueError(
f"Graph has {G.number_of_nodes()} nodes — too large for pyvis. "
f"Use --no-viz or reduce input size."
)
node_community = {n: cid for cid, nodes in communities.items() for n in nodes}
net = Network(height="800px", width="100%", bgcolor="#1a1a2e", font_color="white")
net.barnes_hut()
for node_id, data in G.nodes(data=True):
cid = node_community.get(node_id, 0)
color = COMMUNITY_COLORS[cid % len(COMMUNITY_COLORS)]
net.add_node(
node_id,
label=data.get("label", node_id),
color=color,
title=(
f"Source: {data.get('source_file', 'unknown')}\n"
f"Type: {data.get('file_type', 'unknown')}\n"
f"Community: {community_labels.get(cid, str(cid)) if community_labels else cid}"
),
)
for u, v, data in G.edges(data=True):
confidence = data.get("confidence", "EXTRACTED")
width = {"EXTRACTED": 2, "INFERRED": 1, "AMBIGUOUS": 1}.get(confidence, 1)
net.add_edge(
u, v,
title=f"{data.get('relation', '')} [{confidence}]",
width=width,
dashes=(confidence != "EXTRACTED"),
)
net.save_graph(output_path)
# Inject community legend into saved HTML
if community_labels:
legend_items = ""
for cid in sorted(community_labels.keys()):
color = COMMUNITY_COLORS[cid % len(COMMUNITY_COLORS)]
label = community_labels[cid]
n_nodes = len(communities.get(cid, []))
legend_items += (
f'<div style="margin:4px 0">'
f'<span style="color:{color};font-size:18px">■</span> '
f'<span style="font-size:13px">{label} ({n_nodes})</span>'
f'</div>'
)
legend_html = (
'<div style="position:fixed;top:10px;right:10px;background:#2a2a4e;'
'padding:12px 16px;border-radius:8px;font-family:sans-serif;color:white;'
'z-index:9999;min-width:180px;">'
'<b style="font-size:14px">Communities</b><br>'
+ legend_items +
'</div>'
)
content = Path(output_path).read_text()
content = content.replace("</body>", legend_html + "\n</body>")
Path(output_path).write_text(content)
@@ -1,9 +1,10 @@
"""Tests for analyze.py."""
import json
import networkx as nx
from pathlib import Path
from graphify.graph_builder import build_from_json
from graphify.clusterer import cluster
from graphify.analyzer import god_nodes, surprising_connections, _is_concept_node
from graphify.build import build_from_json
from graphify.cluster import cluster
from graphify.analyze import god_nodes, surprising_connections, _is_concept_node, graph_diff
FIXTURES = Path(__file__).parent / "fixtures"
@@ -114,3 +115,65 @@ def test_surprising_connections_have_required_keys():
assert "target" in s
assert "source_files" in s
assert "confidence" in s
# --- graph_diff tests ---
def _make_simple_graph(nodes, edges):
"""Helper: build a small nx.Graph from node/edge specs."""
G = nx.Graph()
for node_id, label in nodes:
G.add_node(node_id, label=label, source_file="test.py")
for src, tgt, rel, conf in edges:
G.add_edge(src, tgt, relation=rel, confidence=conf)
return G
def test_graph_diff_new_nodes():
G_old = _make_simple_graph([("n1", "Alpha"), ("n2", "Beta")], [])
G_new = _make_simple_graph([("n1", "Alpha"), ("n2", "Beta"), ("n3", "Gamma")], [])
diff = graph_diff(G_old, G_new)
assert len(diff["new_nodes"]) == 1
assert diff["new_nodes"][0]["id"] == "n3"
assert diff["new_nodes"][0]["label"] == "Gamma"
assert diff["removed_nodes"] == []
assert "1 new node" in diff["summary"]
def test_graph_diff_removed_nodes():
G_old = _make_simple_graph([("n1", "Alpha"), ("n2", "Beta"), ("n3", "Gamma")], [])
G_new = _make_simple_graph([("n1", "Alpha"), ("n2", "Beta")], [])
diff = graph_diff(G_old, G_new)
assert diff["new_nodes"] == []
assert len(diff["removed_nodes"]) == 1
assert diff["removed_nodes"][0]["id"] == "n3"
assert "removed" in diff["summary"]
def test_graph_diff_new_edges():
nodes = [("n1", "Alpha"), ("n2", "Beta"), ("n3", "Gamma")]
G_old = _make_simple_graph(nodes, [("n1", "n2", "calls", "EXTRACTED")])
G_new = _make_simple_graph(
nodes,
[("n1", "n2", "calls", "EXTRACTED"), ("n2", "n3", "uses", "INFERRED")],
)
diff = graph_diff(G_old, G_new)
assert len(diff["new_edges"]) == 1
new_edge = diff["new_edges"][0]
assert new_edge["relation"] == "uses"
assert new_edge["confidence"] == "INFERRED"
assert diff["removed_edges"] == []
assert "new edge" in diff["summary"]
def test_graph_diff_empty_diff():
nodes = [("n1", "Alpha"), ("n2", "Beta")]
edges = [("n1", "n2", "calls", "EXTRACTED")]
G_old = _make_simple_graph(nodes, edges)
G_new = _make_simple_graph(nodes, edges)
diff = graph_diff(G_old, G_new)
assert diff["new_nodes"] == []
assert diff["removed_nodes"] == []
assert diff["new_edges"] == []
assert diff["removed_edges"] == []
assert diff["summary"] == "no changes"
@@ -1,6 +1,6 @@
import json
from pathlib import Path
from graphify.graph_builder import build_from_json, build
from graphify.build import build_from_json, build
FIXTURES = Path(__file__).parent / "fixtures"
@@ -39,4 +39,3 @@ def test_build_merges_multiple_extractions():
G = build([ext1, ext2])
assert G.number_of_nodes() == 2
assert G.number_of_edges() == 1
+74
View File
@@ -0,0 +1,74 @@
"""Tests for graphify/cache.py."""
import pytest
from pathlib import Path
from graphify.cache import file_hash, cache_dir, load_cached, save_cached, cached_files, clear_cache
@pytest.fixture
def tmp_file(tmp_path):
f = tmp_path / "sample.txt"
f.write_text("hello world")
return f
@pytest.fixture
def cache_root(tmp_path):
return tmp_path
def test_file_hash_consistent(tmp_file):
"""Same file gives same hash on repeated calls."""
h1 = file_hash(tmp_file)
h2 = file_hash(tmp_file)
assert h1 == h2
assert isinstance(h1, str)
assert len(h1) == 64 # SHA256 hex digest length
def test_file_hash_changes(tmp_path):
"""Different file contents give different hashes."""
f1 = tmp_path / "a.txt"
f2 = tmp_path / "b.txt"
f1.write_text("content one")
f2.write_text("content two")
assert file_hash(f1) != file_hash(f2)
def test_cache_roundtrip(tmp_file, cache_root):
"""Save then load returns the same result dict."""
result = {"nodes": [{"id": "n1", "label": "Node1"}], "edges": []}
save_cached(tmp_file, result, root=cache_root)
loaded = load_cached(tmp_file, root=cache_root)
assert loaded == result
def test_cache_miss_on_change(tmp_file, cache_root):
"""After file content changes, load_cached returns None."""
result = {"nodes": [], "edges": [{"source": "a", "target": "b"}]}
save_cached(tmp_file, result, root=cache_root)
# Modify the file
tmp_file.write_text("completely different content")
assert load_cached(tmp_file, root=cache_root) is None
def test_cached_files(tmp_path, cache_root):
"""cached_files returns the set of cached hashes."""
f1 = tmp_path / "file1.py"
f2 = tmp_path / "file2.py"
f1.write_text("alpha")
f2.write_text("beta")
save_cached(f1, {"nodes": [], "edges": []}, root=cache_root)
save_cached(f2, {"nodes": [], "edges": []}, root=cache_root)
hashes = cached_files(cache_root)
assert file_hash(f1) in hashes
assert file_hash(f2) in hashes
def test_clear_cache(tmp_file, cache_root):
"""clear_cache removes all .json files from .graphify/cache/."""
save_cached(tmp_file, {"nodes": [], "edges": []}, root=cache_root)
assert len(list((cache_root / ".graphify" / "cache").glob("*.json"))) > 0
clear_cache(cache_root)
assert len(list((cache_root / ".graphify" / "cache").glob("*.json"))) == 0
@@ -1,8 +1,8 @@
import json
import networkx as nx
from pathlib import Path
from graphify.graph_builder import build_from_json
from graphify.clusterer import cluster, cohesion_score, score_all
from graphify.build import build_from_json
from graphify.cluster import cluster, cohesion_score, score_all
FIXTURES = Path(__file__).parent / "fixtures"
@@ -1,5 +1,5 @@
from pathlib import Path
from graphify.detector import classify_file, count_words, detect, FileType, _looks_like_paper
from graphify.detect import classify_file, count_words, detect, FileType, _looks_like_paper
FIXTURES = Path(__file__).parent / "fixtures"
@@ -16,7 +16,12 @@ def test_classify_pdf():
assert classify_file(Path("paper.pdf")) == FileType.PAPER
def test_classify_unknown_returns_none():
assert classify_file(Path("image.png")) is None
assert classify_file(Path("archive.zip")) is None
def test_classify_image():
assert classify_file(Path("screenshot.png")) == FileType.IMAGE
assert classify_file(Path("design.jpg")) == FileType.IMAGE
assert classify_file(Path("diagram.webp")) == FileType.IMAGE
def test_count_words_sample_md():
words = count_words(FIXTURES / "sample.md")
@@ -1,9 +1,9 @@
import json
import tempfile
from pathlib import Path
from graphify.graph_builder import build_from_json
from graphify.clusterer import cluster
from graphify.exporter import to_json, to_cypher
from graphify.build import build_from_json
from graphify.cluster import cluster
from graphify.export import to_json, to_cypher
FIXTURES = Path(__file__).parent / "fixtures"
@@ -1,5 +1,5 @@
from pathlib import Path
from graphify.ast_extractor import extract_python, extract, collect_files, _make_id
from graphify.extract import extract_python, extract, collect_files, _make_id
FIXTURES = Path(__file__).parent / "fixtures"
-26
View File
@@ -1,26 +0,0 @@
from graphify.models import Confidence, FileType, GraphNode, GraphEdge, ExtractionResult
def test_confidence_values():
assert Confidence.EXTRACTED.value == "EXTRACTED"
assert Confidence.INFERRED.value == "INFERRED"
assert Confidence.AMBIGUOUS.value == "AMBIGUOUS"
def test_graph_node_defaults():
node = GraphNode(id="n1", label="MyClass", file_type=FileType.CODE, source_file="foo.py")
assert node.community is None
assert node.source_location is None
def test_graph_edge_defaults():
edge = GraphEdge(source="n1", target="n2", relation="imports",
confidence=Confidence.EXTRACTED, source_file="foo.py")
assert edge.weight == 1.0
def test_extraction_result_accumulates():
r = ExtractionResult()
r.nodes.append(GraphNode(id="n1", label="X", file_type=FileType.CODE, source_file="a.py"))
r.edges.append(GraphEdge(source="n1", target="n2", relation="calls",
confidence=Confidence.INFERRED, source_file="a.py"))
assert len(r.nodes) == 1
assert len(r.edges) == 1
r.input_tokens += 100
assert r.input_tokens == 100
@@ -1,9 +1,9 @@
import json
from pathlib import Path
from graphify.graph_builder import build_from_json
from graphify.clusterer import cluster, score_all
from graphify.analyzer import god_nodes, surprising_connections
from graphify.reporter import generate
from graphify.build import build_from_json
from graphify.cluster import cluster, score_all
from graphify.analyze import god_nodes, surprising_connections
from graphify.report import generate
FIXTURES = Path(__file__).parent / "fixtures"
@@ -1,5 +1,5 @@
import pytest
from graphify.validator import validate_extraction, assert_valid
from graphify.validate import validate_extraction, assert_valid
VALID = {
"nodes": [