graphora-kg 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
graphora/__init__.py ADDED
@@ -0,0 +1,36 @@
1
+ """Graphora: a deterministic code knowledge-graph tool.
2
+
3
+ Index a repository into FalkorDB with tree-sitter (no LLM), query blast
4
+ radius for any change, mine git history into a risk memory, and review
5
+ diffs grounded in real structure. Usable as a library, CLI, or MCP server.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import importlib
11
+
12
+ __version__ = "0.2.0"
13
+
14
+ _LAZY = {
15
+ "parse_code_file": "graphora.parser",
16
+ "extract_symbols_from_diff": "graphora.parser",
17
+ "ParsedFile": "graphora.parser",
18
+ "GraphStore": "graphora.store",
19
+ "open_store": "graphora.store",
20
+ "EmbeddedGraphStore": "graphora.embedded",
21
+ "index_repository": "graphora.indexer",
22
+ "update_files": "graphora.indexer",
23
+ "blast_radius": "graphora.blast",
24
+ "BlastRadius": "graphora.blast",
25
+ "mine_risk_memory": "graphora.risk",
26
+ "risk_report": "graphora.risk",
27
+ }
28
+
29
+ __all__ = list(_LAZY) + ["__version__"]
30
+
31
+
32
+ def __getattr__(name: str):
33
+ module_name = _LAZY.get(name)
34
+ if module_name is None:
35
+ raise AttributeError(f"module 'graphora' has no attribute {name!r}")
36
+ return getattr(importlib.import_module(module_name), name)
graphora/benchmark.py ADDED
@@ -0,0 +1,135 @@
1
+ """Reproducible token-cost benchmark. Deterministic, zero LLM, zero network.
2
+
3
+ For a set of query symbols (auto-picked highest-connectivity functions by
4
+ default), compares the prompt context an AI reviewer would need under three
5
+ strategies:
6
+
7
+ 1. whole-repo dump (the context-stuffing anti-pattern)
8
+ 2. equivalent-information file set: the changed files plus every file
9
+ containing a caller or covering test (what a reviewer must actually
10
+ read to learn what the blast radius states directly)
11
+ 3. graphora blast radius (facts read from the graph)
12
+
13
+ Token counts use a fixed 4-chars-per-token heuristic so runs are
14
+ reproducible on any machine with no tokenizer dependency.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from dataclasses import dataclass
20
+ from pathlib import Path
21
+
22
+ from graphora.blast import blast_radius
23
+ from graphora.indexer import collect_source_files
24
+ from graphora.review import estimate_tokens
25
+ from graphora.store import GraphStore
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class BenchmarkResult:
30
+ project: str
31
+ symbols: list[str]
32
+ file_count: int
33
+ repo_tokens: int
34
+ changed_files_tokens: int
35
+ blast_tokens: int
36
+ grounded_facts: dict[str, int]
37
+
38
+ @property
39
+ def saving_vs_repo(self) -> float:
40
+ if self.repo_tokens == 0:
41
+ return 0.0
42
+ return 100.0 * (1 - self.blast_tokens / self.repo_tokens)
43
+
44
+ @property
45
+ def saving_vs_changed_files(self) -> float:
46
+ if self.changed_files_tokens == 0:
47
+ return 0.0
48
+ return 100.0 * (1 - self.blast_tokens / self.changed_files_tokens)
49
+
50
+ def render(self) -> str:
51
+ lines = [
52
+ "# Graphora token-cost benchmark",
53
+ "",
54
+ f"Project: `{self.project}` ({self.file_count} indexed source files)",
55
+ f"Query symbols: {', '.join(f'`{s}`' for s in self.symbols)}",
56
+ "",
57
+ "| Context strategy | Prompt tokens | Relative |",
58
+ "| --- | ---: | ---: |",
59
+ f"| Whole-repo dump | {self.repo_tokens:,} | 100% |",
60
+ f"| Relevant files (changed + callers + tests) | {self.changed_files_tokens:,} |"
61
+ f" {100.0 * self.changed_files_tokens / max(1, self.repo_tokens):.1f}% |",
62
+ f"| **Graphora blast radius** | **{self.blast_tokens:,}** |"
63
+ f" **{100.0 * self.blast_tokens / max(1, self.repo_tokens):.2f}%** |",
64
+ "",
65
+ f"Savings: **{self.saving_vs_repo:.1f}%** vs whole-repo dump,"
66
+ f" **{self.saving_vs_changed_files:.1f}%** vs reading the relevant files.",
67
+ "",
68
+ "Grounded facts included in the blast-radius context (a repo dump has these",
69
+ "only implicitly, buried in the noise):",
70
+ "",
71
+ f"- caller relationships: {self.grounded_facts['callers']}",
72
+ f"- callee relationships: {self.grounded_facts['callees']}",
73
+ f"- covering tests identified: {self.grounded_facts['tests']}",
74
+ f"- risk-memory annotations: {self.grounded_facts['risk_annotations']}",
75
+ "",
76
+ "_Token counts use a fixed 4 chars/token heuristic; the benchmark is fully",
77
+ "deterministic (no LLM, no network) and reproducible with:_",
78
+ "`graphora benchmark <path> --symbols " + ",".join(self.symbols) + "`",
79
+ ]
80
+ return "\n".join(lines)
81
+
82
+
83
+ def pick_symbols(store: GraphStore, count: int = 3) -> list[str]:
84
+ """Deterministically pick the most-connected non-test functions."""
85
+ return store.top_connected_symbols(count)
86
+
87
+
88
+ def run_benchmark(
89
+ store: GraphStore,
90
+ root: str | Path,
91
+ symbols: list[str] | None = None,
92
+ ) -> BenchmarkResult:
93
+ root = Path(root).resolve()
94
+ files = collect_source_files(root)
95
+ repo_tokens = 0
96
+ contents: dict[str, str] = {}
97
+ for path in files:
98
+ text = path.read_text(encoding="utf-8", errors="replace")
99
+ rel = str(path.relative_to(root))
100
+ contents[rel] = text
101
+ repo_tokens += estimate_tokens(text)
102
+
103
+ symbols = symbols or pick_symbols(store)
104
+ if not symbols:
105
+ raise RuntimeError("No symbols to benchmark; index the repository first.")
106
+
107
+ radius = blast_radius(store, symbols)
108
+ blast_tokens = estimate_tokens(radius.to_context())
109
+
110
+ # Fair baseline: to learn what the blast radius states, a reviewer must
111
+ # read the changed files plus every file holding a caller or covering test.
112
+ changed_paths = {impact.path for impact in radius.symbols}
113
+ for impact in radius.symbols:
114
+ changed_paths.update(path for _, path, _ in impact.callers)
115
+ changed_paths.update(path for _, path, _ in impact.tests)
116
+ changed_files_tokens = sum(
117
+ estimate_tokens(contents[p]) for p in changed_paths if p in contents
118
+ )
119
+
120
+ grounded = {
121
+ "callers": sum(len(i.callers) for i in radius.symbols),
122
+ "callees": sum(len(i.callees) for i in radius.symbols),
123
+ "tests": sum(len(i.tests) for i in radius.symbols),
124
+ "risk_annotations": sum(1 for i in radius.symbols if i.fix_count > 0),
125
+ }
126
+
127
+ return BenchmarkResult(
128
+ project=store.project,
129
+ symbols=symbols,
130
+ file_count=len(files),
131
+ repo_tokens=repo_tokens,
132
+ changed_files_tokens=changed_files_tokens,
133
+ blast_tokens=blast_tokens,
134
+ grounded_facts=grounded,
135
+ )
graphora/blast.py ADDED
@@ -0,0 +1,142 @@
1
+ """Deterministic blast radius: pure Cypher, zero LLM.
2
+
3
+ This replaces the old GraphRAG chat-based context building. For every
4
+ changed symbol we read its definition, callers, callees, importers, and
5
+ covering tests straight from the graph, each fact tagged with the edge
6
+ confidence it came from.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass, field
12
+
13
+ from graphora.parser import extract_files_from_diff, extract_symbols_from_diff
14
+ from graphora.store import GraphStore
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class SymbolImpact:
19
+ name: str
20
+ kind: str
21
+ path: str
22
+ line: int
23
+ signature: str
24
+ risk_score: float
25
+ fix_count: int
26
+ last_broke_at: str
27
+ callers: list[tuple[str, str, str]] = field(default_factory=list) # (name, path, confidence)
28
+ callees: list[tuple[str, str, str]] = field(default_factory=list)
29
+ tests: list[tuple[str, str, str]] = field(default_factory=list)
30
+ importers: list[str] = field(default_factory=list)
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class BlastRadius:
35
+ symbols: list[SymbolImpact]
36
+ changed_files: list[str]
37
+ unresolved: list[str]
38
+
39
+ def to_context(self) -> str:
40
+ """Compact, LLM-ready textual context (a few hundred tokens, not a repo dump)."""
41
+ lines: list[str] = ["Blast radius (from code graph, deterministic):"]
42
+ for impact in self.symbols:
43
+ lines.append(f"\n### {impact.name} ({impact.kind})")
44
+ lines.append(f"- defined at {impact.path}:{impact.line} `{impact.signature}`")
45
+ lines.append(_relation_line("callers", impact.callers))
46
+ lines.append(_relation_line("calls", impact.callees))
47
+ lines.append(_relation_line("tests", impact.tests))
48
+ if impact.importers:
49
+ lines.append(f"- imported by ({len(impact.importers)}): {', '.join(impact.importers)}")
50
+ if impact.fix_count:
51
+ lines.append(
52
+ f"- RISK MEMORY: involved in {impact.fix_count} fix/revert commits"
53
+ f" (last: {impact.last_broke_at}), risk score {impact.risk_score:.2f}"
54
+ )
55
+ if self.unresolved:
56
+ lines.append(f"\nSymbols not found in graph: {', '.join(self.unresolved)}")
57
+ return "\n".join(lines)
58
+
59
+ def to_dict(self) -> dict:
60
+ return {
61
+ "changed_files": self.changed_files,
62
+ "unresolved": self.unresolved,
63
+ "symbols": [
64
+ {
65
+ "name": s.name,
66
+ "kind": s.kind,
67
+ "path": s.path,
68
+ "line": s.line,
69
+ "signature": s.signature,
70
+ "risk_score": s.risk_score,
71
+ "fix_count": s.fix_count,
72
+ "last_broke_at": s.last_broke_at,
73
+ "callers": [{"name": n, "path": p, "confidence": c} for n, p, c in s.callers],
74
+ "callees": [{"name": n, "path": p, "confidence": c} for n, p, c in s.callees],
75
+ "tests": [{"name": n, "path": p, "confidence": c} for n, p, c in s.tests],
76
+ "importers": s.importers,
77
+ }
78
+ for s in self.symbols
79
+ ],
80
+ }
81
+
82
+
83
+ def _relation_line(label: str, rows: list[tuple[str, str, str]]) -> str:
84
+ if not rows:
85
+ return f"- {label} (0): none"
86
+ rendered = ", ".join(f"{name} [{path}] ({confidence})" for name, path, confidence in rows)
87
+ return f"- {label} ({len(rows)}): {rendered}"
88
+
89
+
90
+ def blast_radius(store: GraphStore, symbol_names: list[str]) -> BlastRadius:
91
+ """Compute the blast radius for a list of symbol names."""
92
+ impacts: list[SymbolImpact] = []
93
+ unresolved: list[str] = []
94
+ for name in symbol_names:
95
+ definitions = store.find_definitions(name)
96
+ if not definitions:
97
+ unresolved.append(name)
98
+ continue
99
+ for row in definitions:
100
+ impacts.append(_impact_for(store, row))
101
+ return BlastRadius(symbols=impacts, changed_files=[], unresolved=unresolved)
102
+
103
+
104
+ def blast_radius_for_diff(store: GraphStore, diff: str) -> BlastRadius:
105
+ """Compute the blast radius for a unified diff."""
106
+ symbols = extract_symbols_from_diff(diff)
107
+ radius = blast_radius(store, symbols)
108
+ return BlastRadius(
109
+ symbols=radius.symbols,
110
+ changed_files=extract_files_from_diff(diff),
111
+ unresolved=radius.unresolved,
112
+ )
113
+
114
+
115
+ def _impact_for(store: GraphStore, row: list) -> SymbolImpact:
116
+ name, kind, path, line, signature, risk_score, fix_count, last_broke_at = row
117
+ callers = sorted(store.callers_of(name, path))
118
+ callees = sorted(store.callees_of(name, path))
119
+ tests = sorted(store.tests_covering(name, path))
120
+ importers = sorted(store.importers_of_module(_module_hint(path), path))
121
+ return SymbolImpact(
122
+ name=name,
123
+ kind=kind,
124
+ path=path,
125
+ line=int(line),
126
+ signature=signature or "",
127
+ risk_score=float(risk_score or 0.0),
128
+ fix_count=int(fix_count or 0),
129
+ last_broke_at=str(last_broke_at or ""),
130
+ callers=callers,
131
+ callees=callees,
132
+ tests=tests,
133
+ importers=importers,
134
+ )
135
+
136
+
137
+ def _module_hint(path: str) -> str:
138
+ stem = path.rsplit("/", 1)[-1]
139
+ for suffix in (".py", ".ts", ".tsx", ".js", ".jsx", ".go", ".java"):
140
+ if stem.endswith(suffix):
141
+ return stem[: -len(suffix)]
142
+ return stem
graphora/cli.py ADDED
@@ -0,0 +1,227 @@
1
+ """Graphora CLI: the code graph as a tool, not a GitHub App.
2
+
3
+ Usage:
4
+ graphora index PATH [--project NAME]
5
+ graphora stats [--project NAME]
6
+ graphora blast SYMBOL [SYMBOL...] [--project NAME] [--json]
7
+ graphora review [--diff FILE | --git-range RANGE --repo PATH] [--project NAME] [--llm]
8
+ graphora risk mine PATH [--project NAME] [--since DATE]
9
+ graphora risk top [--project NAME] [--limit N]
10
+ graphora benchmark PATH [--project NAME] [--symbols A,B]
11
+ graphora serve-mcp [--project NAME]
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import argparse
17
+ import json
18
+ import subprocess
19
+ import sys
20
+ from pathlib import Path
21
+
22
+ from graphora import __version__
23
+ from graphora.store import open_store
24
+
25
+
26
+ def _store(args: argparse.Namespace):
27
+ return open_store(args.project, backend=args.backend, host=args.host, port=args.port)
28
+
29
+
30
+ def cmd_index(args: argparse.Namespace) -> int:
31
+ from graphora.indexer import index_repository
32
+
33
+ def progress(stage: str, current: int, total: int, path: str) -> None:
34
+ sys.stderr.write(f"\r{stage}: {current}/{total} {path[:60]:<60}")
35
+ sys.stderr.flush()
36
+
37
+ root = Path(args.path).resolve()
38
+ project = args.project or root.name
39
+ store = open_store(project, backend=args.backend, host=args.host, port=args.port)
40
+ index_repository(root, project=project, store=store, on_progress=progress)
41
+ sys.stderr.write("\n")
42
+ print(json.dumps({"project": project, "graph": store.graph_name, "backend": store.backend, **store.stats()}, indent=2))
43
+ return 0
44
+
45
+
46
+ def cmd_stats(args: argparse.Namespace) -> int:
47
+ print(json.dumps(_store(args).stats(), indent=2))
48
+ return 0
49
+
50
+
51
+ def cmd_blast(args: argparse.Namespace) -> int:
52
+ from graphora.blast import blast_radius
53
+
54
+ radius = blast_radius(_store(args), args.symbols)
55
+ print(json.dumps(radius.to_dict(), indent=2) if args.json else radius.to_context())
56
+ return 0
57
+
58
+
59
+ def cmd_review(args: argparse.Namespace) -> int:
60
+ from graphora.review import review_diff
61
+
62
+ if args.diff:
63
+ diff = Path(args.diff).read_text()
64
+ elif args.git_range:
65
+ repo = Path(args.repo or ".").resolve()
66
+ diff = subprocess.run(
67
+ ["git", "--no-pager", "diff", args.git_range],
68
+ cwd=repo, capture_output=True, text=True, check=True,
69
+ ).stdout
70
+ else:
71
+ diff = sys.stdin.read()
72
+ if not diff.strip():
73
+ print("No diff provided (use --diff FILE, --git-range RANGE, or stdin).", file=sys.stderr)
74
+ return 1
75
+ review = review_diff(_store(args), diff, model=args.model, use_llm=args.llm)
76
+ print(review.render())
77
+ return 0
78
+
79
+
80
+ def cmd_risk_mine(args: argparse.Namespace) -> int:
81
+ from graphora.risk import mine_risk_memory
82
+
83
+ root = Path(args.path).resolve()
84
+ project = args.project or root.name
85
+ store = open_store(project, backend=args.backend, host=args.host, port=args.port)
86
+ stats = mine_risk_memory(store, root, since=args.since, max_commits=args.max_commits)
87
+ print(json.dumps(stats.__dict__, indent=2))
88
+ return 0
89
+
90
+
91
+ def cmd_risk_top(args: argparse.Namespace) -> int:
92
+ from graphora.risk import risk_report
93
+
94
+ report = risk_report(_store(args), limit=args.limit)
95
+ if args.json:
96
+ print(json.dumps(report, indent=2))
97
+ return 0
98
+ if not report:
99
+ print("No risk memory yet. Run: graphora risk mine <repo-path>")
100
+ return 0
101
+ print(f"{'RISK':>6} {'FIXES':>5} {'CALLERS':>7} {'LAST BROKE':<12} SYMBOL")
102
+ for row in report:
103
+ print(
104
+ f"{row['risk_score']:>6.2f} {row['fix_count']:>5} {row['caller_count']:>7} "
105
+ f"{row['last_broke_at'][:10]:<12} {row['name']} ({row['path']}:{row['line']})"
106
+ )
107
+ return 0
108
+
109
+
110
+ def cmd_benchmark(args: argparse.Namespace) -> int:
111
+ from graphora.benchmark import run_benchmark
112
+
113
+ root = Path(args.path).resolve()
114
+ project = args.project or root.name
115
+ store = open_store(project, backend=args.backend, host=args.host, port=args.port)
116
+ symbols = [s for s in (args.symbols or "").split(",") if s] or None
117
+ result = run_benchmark(store, root, symbols=symbols)
118
+ print(result.render())
119
+ if args.output:
120
+ Path(args.output).write_text(result.render() + "\n")
121
+ return 0
122
+
123
+
124
+ def cmd_serve_mcp(args: argparse.Namespace) -> int:
125
+ from graphora.mcp_server import serve
126
+
127
+ serve(project=args.project, host=args.host, port=args.port, backend=args.backend)
128
+ return 0
129
+
130
+
131
+ def cmd_install_skill(args: argparse.Namespace) -> int:
132
+ from graphora.skills import install_skill, list_agents
133
+
134
+ if args.list:
135
+ print("\n".join(list_agents()))
136
+ return 0
137
+ agents = None if not args.agents or args.agents == ["all"] else args.agents
138
+ try:
139
+ written = install_skill(args.repo, agents)
140
+ except ValueError as exc:
141
+ print(str(exc), file=sys.stderr)
142
+ return 1
143
+ print(json.dumps({"repo": str(Path(args.repo).resolve()), "written": written}, indent=2))
144
+ return 0
145
+
146
+
147
+ def build_parser() -> argparse.ArgumentParser:
148
+ parser = argparse.ArgumentParser(prog="graphora", description="Deterministic code knowledge graph tool")
149
+ parser.add_argument("--version", action="version", version=f"graphora {__version__}")
150
+
151
+ common = argparse.ArgumentParser(add_help=False)
152
+ common.add_argument("--project", default=None, help="Graph project name (default: directory name)")
153
+ common.add_argument("--host", default="localhost", help="FalkorDB host")
154
+ common.add_argument("--port", type=int, default=6379, help="FalkorDB port")
155
+ common.add_argument(
156
+ "--backend",
157
+ choices=["auto", "falkordb", "embedded"],
158
+ default="auto",
159
+ help="Graph storage: FalkorDB server, embedded JSON (no Docker), or auto-detect (default)",
160
+ )
161
+
162
+ sub = parser.add_subparsers(dest="command", required=True)
163
+
164
+ p_index = sub.add_parser("index", parents=[common], help="Index a repository into the graph (no LLM)")
165
+ p_index.add_argument("path")
166
+ p_index.set_defaults(func=cmd_index)
167
+
168
+ p_stats = sub.add_parser("stats", parents=[common], help="Show graph stats")
169
+ p_stats.set_defaults(func=cmd_stats, project_required=True)
170
+
171
+ p_blast = sub.add_parser("blast", parents=[common], help="Blast radius for symbols")
172
+ p_blast.add_argument("symbols", nargs="+")
173
+ p_blast.add_argument("--json", action="store_true")
174
+ p_blast.set_defaults(func=cmd_blast)
175
+
176
+ p_review = sub.add_parser("review", parents=[common], help="Review a diff grounded in the graph")
177
+ p_review.add_argument("--diff", help="Path to a unified diff file")
178
+ p_review.add_argument("--git-range", help="Git range, e.g. main...HEAD")
179
+ p_review.add_argument("--repo", help="Repo path for --git-range (default: cwd)")
180
+ p_review.add_argument("--llm", action="store_true", help="Use an LLM (REVIEW_MODEL env or --model)")
181
+ p_review.add_argument("--model", default=None)
182
+ p_review.set_defaults(func=cmd_review)
183
+
184
+ p_risk = sub.add_parser("risk", parents=[common], help="Risk memory commands")
185
+ risk_sub = p_risk.add_subparsers(dest="risk_command", required=True)
186
+ p_mine = risk_sub.add_parser("mine", parents=[common], help="Mine git fix/revert history into the graph")
187
+ p_mine.add_argument("path")
188
+ p_mine.add_argument("--since", default=None, help="e.g. '2 years ago'")
189
+ p_mine.add_argument("--max-commits", type=int, default=2000)
190
+ p_mine.set_defaults(func=cmd_risk_mine)
191
+ p_top = risk_sub.add_parser("top", parents=[common], help="Riskiest symbols")
192
+ p_top.add_argument("--limit", type=int, default=15)
193
+ p_top.add_argument("--json", action="store_true")
194
+ p_top.set_defaults(func=cmd_risk_top)
195
+
196
+ p_bench = sub.add_parser("benchmark", parents=[common], help="Reproducible token-cost benchmark")
197
+ p_bench.add_argument("path")
198
+ p_bench.add_argument("--symbols", help="Comma-separated symbols to query (default: auto-pick)")
199
+ p_bench.add_argument("--output", help="Write the report to a file")
200
+ p_bench.set_defaults(func=cmd_benchmark)
201
+
202
+ p_mcp = sub.add_parser("serve-mcp", parents=[common], help="Serve the graph as an MCP stdio server")
203
+ p_mcp.set_defaults(func=cmd_serve_mcp)
204
+
205
+ p_skill = sub.add_parser("install-skill", help="Install the Graphora skill/rule for AI coding agents")
206
+ p_skill.add_argument("agents", nargs="*", help="Agent names, or 'all' (default: all)")
207
+ p_skill.add_argument("--repo", default=".", help="Repository root to install into (default: cwd)")
208
+ p_skill.add_argument("--list", action="store_true", help="List supported agents")
209
+ p_skill.set_defaults(func=cmd_install_skill)
210
+
211
+ return parser
212
+
213
+
214
+ def main(argv: list[str] | None = None) -> int:
215
+ args = build_parser().parse_args(argv)
216
+ if getattr(args, "project", None) is None and args.command not in {"index", "benchmark"}:
217
+ if args.command == "review" and args.repo:
218
+ args.project = Path(args.repo).resolve().name
219
+ elif args.command == "risk" and getattr(args, "path", None):
220
+ args.project = Path(args.path).resolve().name
221
+ else:
222
+ args.project = Path.cwd().name
223
+ return args.func(args)
224
+
225
+
226
+ if __name__ == "__main__":
227
+ sys.exit(main())