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 +36 -0
- graphora/benchmark.py +135 -0
- graphora/blast.py +142 -0
- graphora/cli.py +227 -0
- graphora/embedded.py +329 -0
- graphora/indexer.py +105 -0
- graphora/mcp_server.py +78 -0
- graphora/parser.py +529 -0
- graphora/review.py +163 -0
- graphora/risk.py +186 -0
- graphora/skills.py +152 -0
- graphora/store.py +431 -0
- graphora_kg-0.2.0.dist-info/METADATA +258 -0
- graphora_kg-0.2.0.dist-info/RECORD +18 -0
- graphora_kg-0.2.0.dist-info/WHEEL +5 -0
- graphora_kg-0.2.0.dist-info/entry_points.txt +2 -0
- graphora_kg-0.2.0.dist-info/licenses/LICENSE +201 -0
- graphora_kg-0.2.0.dist-info/top_level.txt +1 -0
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())
|