Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions codegraph/diff.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,13 +110,22 @@ def diff_paths(path_a: str | Path, path_b: str | Path) -> dict:
def diff_git(repo: str | Path, ref_a: str, ref_b: str) -> dict:
"""Diff two git refs by materializing each into a throwaway worktree."""
repo = str(repo)
if not ref_a or not ref_b:
raise ValueError("diff_git: both ref_a and ref_b are required")
with tempfile.TemporaryDirectory(prefix="cg-a-") as ta, \
tempfile.TemporaryDirectory(prefix="cg-b-") as tb:
worktrees = [(ref_a, ta), (ref_b, tb)]
try:
for ref, tmp in worktrees:
subprocess.run(["git", "-C", repo, "worktree", "add", "--detach", tmp, ref],
check=True, capture_output=True, text=True)
try:
subprocess.run(["git", "-C", repo, "worktree", "add", "--detach", tmp, ref],
check=True, capture_output=True, text=True)
except FileNotFoundError as e:
raise RuntimeError("diff_git: git executable not found on PATH") from e
except subprocess.CalledProcessError as e:
detail = (e.stderr or e.stdout or "").strip()
raise RuntimeError(
f"diff_git: could not check out ref {ref!r}: {detail}") from e
return diff_paths(ta, tb)
finally:
for _ref, tmp in worktrees:
Expand Down
23 changes: 19 additions & 4 deletions codegraph/indexer.py
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,9 @@ def index_path(store: Store, root: str | Path, actor: str = "indexer") -> IndexS
root = Path(root).resolve()
stats = IndexStats()
if not root.exists():
raise FileNotFoundError(root)
raise FileNotFoundError(f"index_path: no such directory: {root}")
if not root.is_dir():
raise NotADirectoryError(f"index_path: not a directory: {root}")

for path in iter_source_files(root):
_ingest_file(store, root, path, stats)
Expand All @@ -155,11 +157,24 @@ def index_path(store: Store, root: str | Path, actor: str = "indexer") -> IndexS
return stats


def _run_git(args: list[str], *, what: str) -> str:
"""Run a git command, raising a clear RuntimeError if git fails or is absent."""
try:
proc = subprocess.run(args, check=True, capture_output=True, text=True)
except FileNotFoundError as e: # git not installed / not on PATH
raise RuntimeError(f"{what}: git executable not found on PATH") from e
except subprocess.CalledProcessError as e:
detail = (e.stderr or e.stdout or "").strip()
raise RuntimeError(f"{what}: git failed (exit {e.returncode}): {detail}") from e
return proc.stdout


def changed_files(repo: str | Path, base: str, head: str = "HEAD") -> tuple[list[str], list[str]]:
"""Return (added_or_modified, deleted) paths between two git refs."""
out = subprocess.run(
out = _run_git(
["git", "-C", str(repo), "diff", "--name-status", f"{base}..{head}"],
check=True, capture_output=True, text=True).stdout
what="index_incremental",
)
am: list[str] = []
deleted: list[str] = []
for line in out.splitlines():
Expand Down Expand Up @@ -214,6 +229,6 @@ def index_git(store: Store, url: str, ref: Optional[str] = None, actor: str = "i
if ref:
cmd += ["--branch", ref]
cmd += [url, tmp]
subprocess.run(cmd, check=True, capture_output=True, text=True)
_run_git(cmd, what=f"index_git({url})")
store.set_meta("source_url", url)
return index_path(store, tmp, actor=actor)
6 changes: 6 additions & 0 deletions codegraph/tokens.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,12 @@ def __init__(self, conn: sqlite3.Connection):
self.conn.commit()

def issue(self, label: str, scopes: set[str]) -> tuple[str, TokenInfo]:
if not label or not label.strip():
raise ValueError("token label must be a non-empty string")
scopes = set(scopes)
if not scopes:
raise ValueError("at least one scope is required (one of: "
f"{sorted(VALID_SCOPES)})")
bad = scopes - VALID_SCOPES
if bad:
raise ValueError(f"unknown scopes: {sorted(bad)}")
Expand Down
4 changes: 4 additions & 0 deletions codegraph/viz.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,10 @@ def dot_project(store: Store) -> str:

def render(store: Store, view: str = "project", fmt: str = "mermaid",
symbol_id: Optional[int] = None, max_depth: int = 6) -> str:
if view not in ("project", "impact"):
raise ValueError(f"unknown view {view!r}; expected 'project' or 'impact'")
if fmt not in ("mermaid", "dot"):
raise ValueError(f"unknown format {fmt!r}; expected 'mermaid' or 'dot'")
if view == "impact":
if symbol_id is None:
raise ValueError("impact view requires --symbol")
Expand Down
49 changes: 49 additions & 0 deletions demos/06_mcp_protocol.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
"""Scenario 6 - MCP integrators.

What an agent host actually sees on the wire. We speak raw JSON-RPC 2.0 to the
server in-process: initialize, list tools, then call one - exactly the bytes a
Claude/IDE MCP client exchanges over stdio. No SDK, no network.
"""
import io
import json

from _common import fresh_store, rule
from codegraph.mcp_server import MCPServer


def request(server, payload):
server.outstream = io.StringIO()
server.handle(payload)
out = server.outstream.getvalue().strip()
return json.loads(out) if out else None


def main() -> None:
store = fresh_store()
rule("MCP PROTOCOL - raw JSON-RPC an agent host exchanges over stdio")
server = MCPServer(store)

init = request(server, {"jsonrpc": "2.0", "id": 1, "method": "initialize"})
info = init["result"]["serverInfo"]
print(f"\ninitialize -> {info['name']} v{info['version']} "
f"(protocol {init['result']['protocolVersion']})")

listed = request(server, {"jsonrpc": "2.0", "id": 2, "method": "tools/list"})
tools = listed["result"]["tools"]
print(f"\ntools/list -> {len(tools)} tools the agent can call:")
for t in tools:
req = ",".join(t["inputSchema"]["required"]) or "-"
print(f" - {t['name']:<22} required: {req}")

resp = request(server, {
"jsonrpc": "2.0", "id": 3, "method": "tools/call",
"params": {"name": "search_symbols", "arguments": {"query": "loadUser"}}})
payload = json.loads(resp["result"]["content"][0]["text"])
print(f"\ntools/call search_symbols('loadUser') -> "
f"{payload['results'][0]['name']} [{payload['results'][0]['lang']}]")
print("\nEvery line above is real JSON-RPC; nothing left the machine.")
store.close()


if __name__ == "__main__":
main()
52 changes: 52 additions & 0 deletions demos/07_scoped_tokens.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
"""Scenario 7 - platform security.

Agents get scoped, revocable bearer tokens - never ambient access. This plays
out the full lifecycle: issue a read-only token (it works), try an audit-only
token on a read tool (denied, and the denial is itself logged), then revoke and
watch the next call fail immediately.
"""
import io
import json

from _common import fresh_store, rule
from codegraph.mcp_server import MCPServer
from codegraph.tokens import TokenStore


def call(server, name, **args):
server.outstream = io.StringIO()
server.handle({"jsonrpc": "2.0", "id": 1, "method": "tools/call",
"params": {"name": name, "arguments": args}})
return json.loads(server.outstream.getvalue().strip())


def main() -> None:
store = fresh_store()
rule("SCOPED TOKENS - least privilege for agents, revocable instantly")
ts = TokenStore(store.conn)

reader, rinfo = ts.issue("reader-agent", {"read"})
auditor, ainfo = ts.issue("audit-agent", {"audit"})
print(f"\nIssued 'reader-agent' (scopes={sorted(rinfo.scopes)}) "
f"and 'audit-agent' (scopes={sorted(ainfo.scopes)})")

ok = call(MCPServer(store, token=reader), "graph_stats")
print(f"\nreader-agent calls graph_stats -> isError={ok['result']['isError']} (allowed)")

denied = call(MCPServer(store, token=auditor), "graph_stats")
print(f"audit-agent calls graph_stats -> {denied['error']['message']} (denied)")

ts.revoke(rinfo.id)
print(f"\nRevoked reader-agent (id={rinfo.id}). Authenticating now:")
try:
MCPServer(store, token=reader)
print(" ...unexpectedly accepted")
except PermissionError as e:
print(f" PermissionError: {e} (revocation is immediate)")

print("\nDenials and revocations are themselves audit events - see demo 4.")
store.close()


if __name__ == "__main__":
main()
60 changes: 60 additions & 0 deletions demos/08_graph_diff.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
"""Scenario 8 - reviewers diffing a change.

A line diff tells you what text changed. A *graph* diff tells you what changed
in the shape of the code: symbols added/removed, signatures changed, endpoints
moved, and - uniquely - cross-language dependencies added or broken. Here we
break an API contract and watch the graph diff surface the cross-language fallout.
"""
import os
import tempfile

from _common import rule
from codegraph.diff import diff_paths


def write(d, rel, text):
p = os.path.join(d, rel)
os.makedirs(os.path.dirname(p), exist_ok=True)
with open(p, "w", encoding="utf-8") as f:
f.write(text)


def main() -> None:
rule("GRAPH DIFF - what changed in the *shape* of the code")
a = tempfile.mkdtemp(prefix="cg_diff_a_")
b = tempfile.mkdtemp(prefix="cg_diff_b_")

# BEFORE: a TS client and a Python handler agree on /api/users/{id}
write(a, "web/client.ts",
"export async function loadUser(id) {\n"
" return fetch(`/api/users/${id}`);\n}\n")
write(a, "api/svc.py",
"@app.route('/api/users/<id>')\ndef get_user(id):\n return id\n")

# AFTER: the backend route is renamed to /api/accounts/{id} -> edge breaks,
# and get_user gains a parameter.
write(b, "web/client.ts",
"export async function loadUser(id) {\n"
" return fetch(`/api/users/${id}`);\n}\n")
write(b, "api/svc.py",
"@app.route('/api/accounts/<id>')\ndef get_user(id, fields):\n return id\n")

d = diff_paths(a, b)
s = d["summary"]
print(f"\nsignature changes: {s['signatures_changed']}")
for c in d["symbols"]["signature_changed"]:
print(f" {c['symbol']}: {c['old_signature']} -> {c['new_signature']}")
print(f"\nendpoints removed: {s['endpoints_removed']}, added: {s['endpoints_added']}")
for e in d["endpoints"]["removed"]:
print(f" - {e['method']} {e['route']} ({e['symbol']})")
for e in d["endpoints"]["added"]:
print(f" + {e['method']} {e['route']} ({e['symbol']})")
print(f"\ncross-language edges removed: {s['cross_language_edges_removed']}")
for e in d["cross_language_edges"]["removed"]:
print(f" BROKEN: {e['from']} -> {e['to']}")
print("\nThe TS client still calls the old route - the graph diff caught the break a "
"line diff would never connect.")


if __name__ == "__main__":
main()
65 changes: 65 additions & 0 deletions demos/09_incremental_index.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
"""Scenario 9 - CI / pre-commit integrations.

On a large repo you don't want to re-parse the world on every commit. codegraph
indexes incrementally: only files changed between two git refs are re-parsed,
yet the global call/cross-language graph stays correct because edges are
recomputed from the tables afterward. This demo builds a tiny git repo, makes a
commit, and re-indexes just the delta.
"""
import os
import subprocess
import tempfile

from _common import rule
from codegraph.graph import Store
from codegraph.indexer import changed_files, index_incremental, index_path


def git(repo, *args):
subprocess.run(["git", "-C", repo, *args], check=True, capture_output=True, text=True)


def write(repo, rel, text):
with open(os.path.join(repo, rel), "w", encoding="utf-8") as f:
f.write(text)


def main() -> None:
rule("INCREMENTAL INDEX - re-parse only what a commit touched")
repo = tempfile.mkdtemp(prefix="cg_inc_")
git(repo, "init", "-q")
git(repo, "config", "user.email", "demo@demo")
git(repo, "config", "user.name", "demo")
write(repo, "a.py", "def f():\n return g()\n\ndef g():\n return 1\n")
write(repo, "b.py", "def h():\n return 2\n")
git(repo, "add", "-A")
git(repo, "commit", "-q", "-m", "c1")
base = subprocess.run(["git", "-C", repo, "rev-parse", "HEAD"],
capture_output=True, text=True).stdout.strip()

store = Store(":memory:")
index_path(store, repo)
print(f"\nFull index: {store.stats()['symbols']} symbols (f, g, h)")

# commit a change: add new() calling f, delete b.py
write(repo, "a.py",
"def f():\n return g()\n\ndef g():\n return 1\n\ndef new():\n return f()\n")
os.remove(os.path.join(repo, "b.py"))
git(repo, "add", "-A")
git(repo, "commit", "-q", "-m", "c2")

am, deleted = changed_files(repo, base, "HEAD")
print(f"\nchanged since {base[:7]}: modified/added={am} deleted={deleted}")

stats = index_incremental(store, repo, base, "HEAD")
print(f"\nIncremental re-index parsed {stats.files} file(s) (only a.py).")
print(f" 'h' (from deleted b.py) gone: {store.symbols_by_name('h') == []}")
new = store.symbols_by_name("new")
reaches_f = bool(new) and any(c.name == "f" for c in store.callees_of(new[0].id))
print(f" new edge new -> f resolved by global rebuild: {reaches_f}")
print("\nTouch one file, keep a correct whole-repo graph.")
store.close()


if __name__ == "__main__":
main()
57 changes: 57 additions & 0 deletions demos/10_http_transport.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
"""Scenario 10 - hosts that can't spawn a subprocess.

Some agent platforms POST JSON-RPC over HTTP instead of stdio. codegraph serves
the identical tool surface, dispatch logic, scope checks, and audit log over a
tiny stdlib HTTP server. This demo starts one on an ephemeral port, hits
/health, and calls a tool with a bearer token - all over the loopback socket.
"""
import json
import threading
import urllib.request

from _common import SAMPLE, rule
from codegraph.graph import Store
from codegraph.http_server import serve_http
from codegraph.indexer import index_path
from codegraph.tokens import TokenStore


def post(port, payload, token=None):
req = urllib.request.Request(
f"http://127.0.0.1:{port}/mcp", data=json.dumps(payload).encode(),
headers={"Content-Type": "application/json"})
if token:
req.add_header("Authorization", f"Bearer {token}")
with urllib.request.urlopen(req, timeout=5) as r:
return json.loads(r.read())


def main() -> None:
rule("HTTP TRANSPORT - same MCP tools, over a loopback socket")
# HTTP serves from a worker thread -> the connection must allow cross-thread use
store = Store(":memory:", check_same_thread=False)
index_path(store, SAMPLE)

token, _ = TokenStore(store.conn).issue("http-agent", {"read"})
httpd = serve_http(store, "127.0.0.1", 0)
port = httpd.server_address[1]
threading.Thread(target=httpd.serve_forever, daemon=True).start()
try:
health = urllib.request.urlopen(f"http://127.0.0.1:{port}/health", timeout=5)
print(f"\nGET /health -> {json.loads(health.read())}")

resp = post(port, {"jsonrpc": "2.0", "id": 1, "method": "tools/call",
"params": {"name": "graph_stats", "arguments": {}}}, token=token)
stats = json.loads(resp["result"]["content"][0]["text"])
print(f"\nPOST /mcp graph_stats (Bearer http-agent) -> "
f"{stats['files']} files, {stats['symbols']} symbols, "
f"{stats['cross_language_edges']} cross-language edges")
print("\nIdentical behaviour to stdio - only the transport differs.")
finally:
httpd.shutdown()
httpd.server_close()
store.close()


if __name__ == "__main__":
main()
Loading
Loading