codegraph-kit 0.1.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.
- codegraph_kit/__init__.py +4 -0
- codegraph_kit/cli.py +106 -0
- codegraph_kit/core.py +181 -0
- codegraph_kit/mcp_server.py +84 -0
- codegraph_kit/queries.py +81 -0
- codegraph_kit-0.1.0.dist-info/METADATA +129 -0
- codegraph_kit-0.1.0.dist-info/RECORD +10 -0
- codegraph_kit-0.1.0.dist-info/WHEEL +4 -0
- codegraph_kit-0.1.0.dist-info/entry_points.txt +2 -0
- codegraph_kit-0.1.0.dist-info/licenses/LICENSE +21 -0
codegraph_kit/cli.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""codegraph — cached, incremental code-graph maps so agents query structure
|
|
2
|
+
instead of reading whole files. Backed by graphify (tree-sitter AST extraction).
|
|
3
|
+
|
|
4
|
+
codegraph build [dir] full graph build (registers repo root)
|
|
5
|
+
codegraph ensure [dir] incremental refresh (only changed files re-extracted)
|
|
6
|
+
codegraph map [dir] compact overview: counts, per-dir breakdown, top hubs
|
|
7
|
+
codegraph file <path> outline of one file (defs + line ranges -> sliced reads)
|
|
8
|
+
codegraph sym <name> [dir] locate symbol by substring -> file + lines
|
|
9
|
+
codegraph callers <name> [dir] who calls it
|
|
10
|
+
codegraph callees <name> [dir] what it calls
|
|
11
|
+
codegraph deps <path> imports of a file
|
|
12
|
+
codegraph touch <path> re-extract one file into its repo's cache (hook use)
|
|
13
|
+
codegraph agent print an instruction snippet for AGENTS.md / CLAUDE.md
|
|
14
|
+
codegraph mcp run as an MCP server (requires `pip install codegraph-kit[mcp]`)
|
|
15
|
+
codegraph --version print version
|
|
16
|
+
|
|
17
|
+
Queries auto-run `ensure` first, so the graph tracks the working tree.
|
|
18
|
+
"""
|
|
19
|
+
import sys
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
from . import __version__, core, queries
|
|
23
|
+
|
|
24
|
+
AGENT_SNIPPET = """\
|
|
25
|
+
## Code navigation — graph first, read only what you need
|
|
26
|
+
|
|
27
|
+
This repo has `codegraph` (cached tree-sitter code graphs). Before reading
|
|
28
|
+
source files, query the graph and then read ONLY the located line ranges:
|
|
29
|
+
|
|
30
|
+
- `codegraph map` — repo overview: size, per-directory breakdown, top hub symbols
|
|
31
|
+
- `codegraph file <path>` — outline of a file (definitions + line ranges)
|
|
32
|
+
- `codegraph sym <name>` — locate a symbol -> file:lines
|
|
33
|
+
- `codegraph callers <name>` / `codegraph callees <name>` — trace call edges
|
|
34
|
+
- `codegraph deps <path>` — what a file imports
|
|
35
|
+
|
|
36
|
+
Run `codegraph build` once per repo; afterwards every query refreshes changed
|
|
37
|
+
files automatically. Fall back to reading whole files only when the graph
|
|
38
|
+
can't answer (unsupported language, dynamic dispatch, subtle logic).
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def main(argv=None):
|
|
43
|
+
args = sys.argv[1:] if argv is None else argv
|
|
44
|
+
if not args or args[0] in ("-h", "--help", "help"):
|
|
45
|
+
print(__doc__)
|
|
46
|
+
return 0
|
|
47
|
+
cmd = args[0]
|
|
48
|
+
tail = args[1:]
|
|
49
|
+
|
|
50
|
+
if cmd in ("--version", "-V", "version"):
|
|
51
|
+
print(f"codegraph {__version__}")
|
|
52
|
+
return 0
|
|
53
|
+
if cmd == "agent":
|
|
54
|
+
print(AGENT_SNIPPET)
|
|
55
|
+
return 0
|
|
56
|
+
if cmd == "mcp":
|
|
57
|
+
from .mcp_server import run # lazy: needs the [mcp] extra
|
|
58
|
+
run()
|
|
59
|
+
return 0
|
|
60
|
+
|
|
61
|
+
target = None
|
|
62
|
+
if cmd in ("build", "ensure", "map", "hubs"):
|
|
63
|
+
root = core.find_root(Path(tail[0]).resolve() if tail else Path.cwd())
|
|
64
|
+
elif cmd in ("file", "deps", "touch"):
|
|
65
|
+
if not tail:
|
|
66
|
+
print(f"usage: codegraph {cmd} <path>", file=sys.stderr)
|
|
67
|
+
return 2
|
|
68
|
+
target = Path(tail[0]).resolve()
|
|
69
|
+
root = core.find_root(target)
|
|
70
|
+
elif cmd in ("sym", "callers", "callees"):
|
|
71
|
+
if not tail:
|
|
72
|
+
print(f"usage: codegraph {cmd} <name> [dir]", file=sys.stderr)
|
|
73
|
+
return 2
|
|
74
|
+
root = core.find_root(Path(tail[1]).resolve() if len(tail) > 1 else Path.cwd())
|
|
75
|
+
else:
|
|
76
|
+
print(f"unknown command: {cmd}")
|
|
77
|
+
print(__doc__)
|
|
78
|
+
return 2
|
|
79
|
+
|
|
80
|
+
if cmd == "build":
|
|
81
|
+
g, idx, n = core.build(root)
|
|
82
|
+
print(f"built {root}: {n} files -> {len(g['nodes'])} nodes, {len(g['edges'])} edges")
|
|
83
|
+
return 0
|
|
84
|
+
if cmd == "touch":
|
|
85
|
+
core.touch(target, root)
|
|
86
|
+
return 0
|
|
87
|
+
|
|
88
|
+
g = core.ensure(root)
|
|
89
|
+
|
|
90
|
+
if cmd == "ensure":
|
|
91
|
+
print(f"{root}: {len(g['nodes'])} nodes, {len(g['edges'])} edges (fresh)")
|
|
92
|
+
elif cmd in ("map", "hubs"):
|
|
93
|
+
print(queries.q_map(root, g))
|
|
94
|
+
elif cmd == "file":
|
|
95
|
+
print(queries.q_file(root, g, target))
|
|
96
|
+
elif cmd == "sym":
|
|
97
|
+
print(queries.q_sym(root, g, tail[0]))
|
|
98
|
+
elif cmd in ("callers", "callees"):
|
|
99
|
+
print(queries.q_calls(root, g, tail[0], cmd))
|
|
100
|
+
elif cmd == "deps":
|
|
101
|
+
print(queries.q_deps(root, g, target))
|
|
102
|
+
return 0
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
if __name__ == "__main__":
|
|
106
|
+
sys.exit(main())
|
codegraph_kit/core.py
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""Graph building, caching, and incremental refresh.
|
|
2
|
+
|
|
3
|
+
Graphs are stored per-repo under the cache dir (default ~/.cache/codegraph,
|
|
4
|
+
override with $CODEGRAPH_CACHE). Extraction is delegated to graphify
|
|
5
|
+
(tree-sitter AST parsing); this layer adds root discovery, mtime-based
|
|
6
|
+
incremental rebuilds, and root-relative path normalization.
|
|
7
|
+
"""
|
|
8
|
+
import hashlib
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import sys
|
|
12
|
+
import time
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
CODE_EXTS = {".py", ".js", ".ts", ".tsx", ".jsx", ".mjs", ".go", ".rs", ".java",
|
|
16
|
+
".rb", ".c", ".h", ".cpp", ".hpp", ".cs", ".php", ".swift", ".kt", ".sh"}
|
|
17
|
+
SKIP_DIRS = {".git", "node_modules", "venv", ".venv", "__pycache__", "dist", "build",
|
|
18
|
+
".next", "target", ".cache", "vendor", "site-packages", ".tox", "coverage"}
|
|
19
|
+
MAX_FILES = 5000
|
|
20
|
+
MAX_FILE_BYTES = 1_000_000
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def cache_dir() -> Path:
|
|
24
|
+
env = os.environ.get("CODEGRAPH_CACHE")
|
|
25
|
+
return Path(env) if env else Path.home() / ".cache" / "codegraph"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def roots_file() -> Path:
|
|
29
|
+
return cache_dir() / "roots.json"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def repo_key(root: Path) -> Path:
|
|
33
|
+
return cache_dir() / hashlib.sha1(str(root).encode()).hexdigest()[:16]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def find_root(start: Path) -> Path:
|
|
37
|
+
p = start if start.is_dir() else start.parent
|
|
38
|
+
for q in [p, *p.parents]:
|
|
39
|
+
if (q / ".git").exists():
|
|
40
|
+
return q
|
|
41
|
+
return p
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def code_files(root: Path):
|
|
45
|
+
out = []
|
|
46
|
+
for dirpath, dirnames, filenames in os.walk(root):
|
|
47
|
+
dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS and not d.startswith(".")]
|
|
48
|
+
for f in filenames:
|
|
49
|
+
fp = Path(dirpath) / f
|
|
50
|
+
if fp.suffix in CODE_EXTS and fp.stat().st_size < MAX_FILE_BYTES:
|
|
51
|
+
out.append(fp)
|
|
52
|
+
if len(out) > MAX_FILES:
|
|
53
|
+
print(f"WARNING: {len(out)} code files; graphing first {MAX_FILES} "
|
|
54
|
+
f"(largest dirs may be partial)", file=sys.stderr)
|
|
55
|
+
out = out[:MAX_FILES]
|
|
56
|
+
return out
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def load(root: Path):
|
|
60
|
+
d = repo_key(root)
|
|
61
|
+
try:
|
|
62
|
+
graph = json.loads((d / "graph.json").read_text())
|
|
63
|
+
idx = json.loads((d / "index.json").read_text())
|
|
64
|
+
return graph, idx
|
|
65
|
+
except Exception:
|
|
66
|
+
return None, None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def save(root: Path, graph, idx):
|
|
70
|
+
d = repo_key(root)
|
|
71
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
72
|
+
(d / "graph.json").write_text(json.dumps(graph))
|
|
73
|
+
(d / "index.json").write_text(json.dumps(idx))
|
|
74
|
+
rf = roots_file()
|
|
75
|
+
try:
|
|
76
|
+
roots = json.loads(rf.read_text()) if rf.exists() else {}
|
|
77
|
+
except Exception:
|
|
78
|
+
roots = {}
|
|
79
|
+
roots[str(root)] = time.strftime("%Y-%m-%dT%H:%M:%S")
|
|
80
|
+
rf.write_text(json.dumps(roots, indent=1))
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def extract_files(paths, root):
|
|
84
|
+
"""Extract and normalize source_file to root-relative. graphify stores paths
|
|
85
|
+
relative to the common ancestor of the batch (basename for single/same-dir
|
|
86
|
+
batches), so we resolve via that ancestor. Unattributable ('' semantic) nodes
|
|
87
|
+
are dropped — they'd break incremental dedup."""
|
|
88
|
+
from graphify.extract import extract
|
|
89
|
+
ok_nodes, ok_edges, failed = [], [], []
|
|
90
|
+
resolved = [p.resolve() for p in paths]
|
|
91
|
+
if not resolved:
|
|
92
|
+
return ok_nodes, ok_edges, failed
|
|
93
|
+
common = Path(os.path.commonpath([str(p) for p in resolved])) if len(resolved) > 1 else resolved[0].parent
|
|
94
|
+
if common.is_file():
|
|
95
|
+
common = common.parent
|
|
96
|
+
|
|
97
|
+
def norm(sf):
|
|
98
|
+
if not sf:
|
|
99
|
+
return None
|
|
100
|
+
try:
|
|
101
|
+
return str((common / sf).resolve().relative_to(root))
|
|
102
|
+
except ValueError:
|
|
103
|
+
return None
|
|
104
|
+
|
|
105
|
+
def collect(r):
|
|
106
|
+
for n in r["nodes"]:
|
|
107
|
+
sf = norm(n.get("source_file", ""))
|
|
108
|
+
if sf:
|
|
109
|
+
n["source_file"] = sf
|
|
110
|
+
ok_nodes.append(n)
|
|
111
|
+
for e in r["edges"]:
|
|
112
|
+
sf = norm(e.get("source_file", ""))
|
|
113
|
+
if sf:
|
|
114
|
+
e["source_file"] = sf
|
|
115
|
+
ok_edges.append(e)
|
|
116
|
+
|
|
117
|
+
try:
|
|
118
|
+
collect(extract(paths=resolved, parallel=len(resolved) > 4))
|
|
119
|
+
except Exception:
|
|
120
|
+
for p in resolved:
|
|
121
|
+
common = p.parent
|
|
122
|
+
try:
|
|
123
|
+
collect(extract(paths=[p], parallel=False))
|
|
124
|
+
except Exception:
|
|
125
|
+
failed.append(str(p))
|
|
126
|
+
return ok_nodes, ok_edges, failed
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def build(root: Path, only_changed=False):
|
|
130
|
+
files = code_files(root)
|
|
131
|
+
graph, idx = load(root)
|
|
132
|
+
mtimes = {str(f.relative_to(root)): f.stat().st_mtime for f in files}
|
|
133
|
+
if only_changed and graph and idx:
|
|
134
|
+
old = idx.get("mtimes", {})
|
|
135
|
+
changed = [f for f in files if old.get(str(f.relative_to(root))) != f.stat().st_mtime]
|
|
136
|
+
deleted = set(old) - set(mtimes)
|
|
137
|
+
if not changed and not deleted:
|
|
138
|
+
return graph, idx, 0
|
|
139
|
+
drop = {str(f.relative_to(root)) for f in changed} | deleted
|
|
140
|
+
nodes = [n for n in graph["nodes"] if n.get("source_file", "") not in drop]
|
|
141
|
+
edges = [e for e in graph["edges"] if e.get("source_file", "") not in drop]
|
|
142
|
+
new_n, new_e, failed = extract_files(changed, root)
|
|
143
|
+
graph = {"nodes": nodes + new_n, "edges": edges + new_e}
|
|
144
|
+
n_processed = len(changed)
|
|
145
|
+
else:
|
|
146
|
+
new_n, new_e, failed = extract_files(files, root)
|
|
147
|
+
graph = {"nodes": new_n, "edges": new_e}
|
|
148
|
+
n_processed = len(files)
|
|
149
|
+
if failed:
|
|
150
|
+
print(f"WARNING: {len(failed)} files failed extraction: {failed[:5]}", file=sys.stderr)
|
|
151
|
+
idx = {"root": str(root), "mtimes": mtimes, "built": time.strftime("%Y-%m-%dT%H:%M:%S"),
|
|
152
|
+
"failed": failed}
|
|
153
|
+
save(root, graph, idx)
|
|
154
|
+
return graph, idx, n_processed
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def ensure(root: Path):
|
|
158
|
+
graph, idx, n = build(root, only_changed=True)
|
|
159
|
+
if n:
|
|
160
|
+
print(f"[codegraph] refreshed {n} file(s)", file=sys.stderr)
|
|
161
|
+
return graph
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def touch(target: Path, root: Path):
|
|
165
|
+
"""Re-extract one file into its repo's cached graph. No-op when the repo
|
|
166
|
+
has no graph yet (hooks call this on every edit)."""
|
|
167
|
+
g, idx = load(root)
|
|
168
|
+
if not g:
|
|
169
|
+
return
|
|
170
|
+
f = str(target.relative_to(root))
|
|
171
|
+
nodes = [n for n in g["nodes"] if n.get("source_file", "") != f]
|
|
172
|
+
edges = [e for e in g["edges"] if e.get("source_file", "") != f]
|
|
173
|
+
if target.exists() and target.suffix in CODE_EXTS:
|
|
174
|
+
nn, ne, _ = extract_files([target], root)
|
|
175
|
+
nodes += nn
|
|
176
|
+
edges += ne
|
|
177
|
+
if target.exists():
|
|
178
|
+
idx["mtimes"][f] = target.stat().st_mtime
|
|
179
|
+
else:
|
|
180
|
+
idx["mtimes"].pop(f, None)
|
|
181
|
+
save(root, {"nodes": nodes, "edges": edges}, idx)
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""MCP server exposing codegraph queries as tools, for agents that speak MCP
|
|
2
|
+
instead of shelling out. Requires the `mcp` extra: pip install codegraph-kit[mcp]
|
|
3
|
+
|
|
4
|
+
Run: codegraph mcp (stdio transport)
|
|
5
|
+
"""
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from . import core, queries
|
|
9
|
+
|
|
10
|
+
try:
|
|
11
|
+
from mcp.server.fastmcp import FastMCP
|
|
12
|
+
except ImportError as e: # pragma: no cover
|
|
13
|
+
raise SystemExit(
|
|
14
|
+
"MCP support needs the optional dependency: pip install codegraph-kit[mcp]"
|
|
15
|
+
) from e
|
|
16
|
+
|
|
17
|
+
server = FastMCP(
|
|
18
|
+
"codegraph",
|
|
19
|
+
instructions=(
|
|
20
|
+
"Cached tree-sitter code graphs. Query structure (outlines, symbols, "
|
|
21
|
+
"call edges, imports) instead of reading whole files; then read only "
|
|
22
|
+
"the located line ranges."
|
|
23
|
+
),
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _root(directory: str) -> Path:
|
|
28
|
+
return core.find_root(Path(directory).resolve())
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@server.tool()
|
|
32
|
+
def graph_map(directory: str) -> str:
|
|
33
|
+
"""Repo overview: node/edge counts, per-directory breakdown, top hub symbols."""
|
|
34
|
+
root = _root(directory)
|
|
35
|
+
return queries.q_map(root, core.ensure(root))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@server.tool()
|
|
39
|
+
def file_outline(path: str) -> str:
|
|
40
|
+
"""Outline of one source file: definitions with line ranges."""
|
|
41
|
+
target = Path(path).resolve()
|
|
42
|
+
root = core.find_root(target)
|
|
43
|
+
return queries.q_file(root, core.ensure(root), target)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@server.tool()
|
|
47
|
+
def find_symbol(name: str, directory: str = ".") -> str:
|
|
48
|
+
"""Locate a symbol by substring -> file:lines."""
|
|
49
|
+
root = _root(directory)
|
|
50
|
+
return queries.q_sym(root, core.ensure(root), name)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@server.tool()
|
|
54
|
+
def callers(name: str, directory: str = ".") -> str:
|
|
55
|
+
"""Who calls this symbol (matched by substring)."""
|
|
56
|
+
root = _root(directory)
|
|
57
|
+
return queries.q_calls(root, core.ensure(root), name, "callers")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@server.tool()
|
|
61
|
+
def callees(name: str, directory: str = ".") -> str:
|
|
62
|
+
"""What this symbol calls (matched by substring)."""
|
|
63
|
+
root = _root(directory)
|
|
64
|
+
return queries.q_calls(root, core.ensure(root), name, "callees")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@server.tool()
|
|
68
|
+
def file_deps(path: str) -> str:
|
|
69
|
+
"""Imports of a source file."""
|
|
70
|
+
target = Path(path).resolve()
|
|
71
|
+
root = core.find_root(target)
|
|
72
|
+
return queries.q_deps(root, core.ensure(root), target)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@server.tool()
|
|
76
|
+
def build_graph(directory: str) -> str:
|
|
77
|
+
"""Full (re)build of a repo's graph. Run once per repo; queries refresh incrementally."""
|
|
78
|
+
root = _root(directory)
|
|
79
|
+
g, _idx, n = core.build(root)
|
|
80
|
+
return f"built {root}: {n} files -> {len(g['nodes'])} nodes, {len(g['edges'])} edges"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def run():
|
|
84
|
+
server.run()
|
codegraph_kit/queries.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Query functions over a built graph. Each returns a plain string ready to
|
|
2
|
+
show to a human or an agent — the CLI prints it, the MCP server returns it."""
|
|
3
|
+
from collections import Counter
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from . import core
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def loc(n):
|
|
10
|
+
return f"{n.get('source_file', '?')}:{n.get('source_location', '?')}"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def fmt_node(n):
|
|
14
|
+
return f"{n.get('label', n['id'])} [{n.get('type', n.get('_origin', '?'))}] {loc(n)}"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def fresh_graph(root: Path):
|
|
18
|
+
return core.ensure(root)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def q_map(root: Path, g) -> str:
|
|
22
|
+
nodes, edges = g["nodes"], g["edges"]
|
|
23
|
+
deg = Counter()
|
|
24
|
+
for e in edges:
|
|
25
|
+
deg[e["source"]] += 1
|
|
26
|
+
deg[e["target"]] += 1
|
|
27
|
+
byid = {n["id"]: n for n in nodes}
|
|
28
|
+
per_dir = Counter(n.get("source_file", "?").split("/")[0] for n in nodes)
|
|
29
|
+
rels = Counter(e.get("relation", "?") for e in edges)
|
|
30
|
+
lines = [f"{root} — {len(nodes)} nodes, {len(edges)} edges",
|
|
31
|
+
f"per-dir: {dict(per_dir.most_common(12))}",
|
|
32
|
+
f"relations: {dict(rels.most_common())}",
|
|
33
|
+
"top hubs:"]
|
|
34
|
+
for nid, d in deg.most_common(15):
|
|
35
|
+
n = byid.get(nid)
|
|
36
|
+
if n:
|
|
37
|
+
lines.append(f" {d:4d} {fmt_node(n)}")
|
|
38
|
+
return "\n".join(lines)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def q_file(root: Path, g, target: Path) -> str:
|
|
42
|
+
f = str(target.resolve().relative_to(root))
|
|
43
|
+
mine = [n for n in g["nodes"] if n.get("source_file", "") == f]
|
|
44
|
+
if not mine:
|
|
45
|
+
return f"(no graph entries for {f} — unsupported language or empty; read it directly)"
|
|
46
|
+
return "\n".join(fmt_node(n) for n in sorted(mine, key=lambda x: x.get("source_location", "")))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def q_sym(root: Path, g, query: str, limit: int = 30) -> str:
|
|
50
|
+
q = query.lower()
|
|
51
|
+
hits = [n for n in g["nodes"] if q in n.get("label", "").lower() or q in n["id"].lower()]
|
|
52
|
+
if not hits:
|
|
53
|
+
return f"no symbol matching '{query}' — try grep"
|
|
54
|
+
lines = [fmt_node(n) for n in hits[:limit]]
|
|
55
|
+
if len(hits) > limit:
|
|
56
|
+
lines.append(f"... {len(hits) - limit} more (narrow the query)")
|
|
57
|
+
return "\n".join(lines)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def q_calls(root: Path, g, query: str, direction: str, limit: int = 40) -> str:
|
|
61
|
+
"""direction: 'callers' (who calls it) or 'callees' (what it calls)."""
|
|
62
|
+
q = query.lower()
|
|
63
|
+
byid = {n["id"]: n for n in g["nodes"]}
|
|
64
|
+
key, other = ("target", "source") if direction == "callers" else ("source", "target")
|
|
65
|
+
hits = [e for e in g["edges"] if e.get("relation") == "calls" and q in e[key].lower()]
|
|
66
|
+
if not hits:
|
|
67
|
+
return (f"no call edges matching '{query}' "
|
|
68
|
+
"(dynamic dispatch isn't captured — fall back to grep)")
|
|
69
|
+
lines = []
|
|
70
|
+
for e in hits[:limit]:
|
|
71
|
+
n = byid.get(e[other])
|
|
72
|
+
lines.append(fmt_node(n) if n else
|
|
73
|
+
f"{e[other]} ({e.get('source_file', '?')}:{e.get('source_location', '?')})")
|
|
74
|
+
return "\n".join(lines)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def q_deps(root: Path, g, target: Path) -> str:
|
|
78
|
+
f = str(target.resolve().relative_to(root))
|
|
79
|
+
lines = [f"{e['relation']:13s} {e['target']}" for e in g["edges"]
|
|
80
|
+
if e.get("source_file", "") == f and e.get("relation") in ("imports", "imports_from")]
|
|
81
|
+
return "\n".join(lines) if lines else f"(no import edges recorded for {f})"
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: codegraph-kit
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Cached, incremental code-graph maps so AI agents query code structure instead of reading whole files. Works with any agent: CLI for Claude Code/Codex/Cursor/Aider, optional MCP server.
|
|
5
|
+
Project-URL: Homepage, https://github.com/nguyenminhduc9988/codegraph
|
|
6
|
+
Project-URL: Repository, https://github.com/nguyenminhduc9988/codegraph
|
|
7
|
+
Project-URL: Issues, https://github.com/nguyenminhduc9988/codegraph/issues
|
|
8
|
+
Author: Duc Nguyen
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: ai-agents,claude-code,code-graph,codebase-map,mcp,static-analysis,token-efficiency,tree-sitter
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
21
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
22
|
+
Requires-Python: >=3.10
|
|
23
|
+
Requires-Dist: graphifyy>=0.9
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: build; extra == 'dev'
|
|
26
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
27
|
+
Requires-Dist: twine; extra == 'dev'
|
|
28
|
+
Provides-Extra: mcp
|
|
29
|
+
Requires-Dist: mcp>=1.0; extra == 'mcp'
|
|
30
|
+
Description-Content-Type: text/markdown
|
|
31
|
+
|
|
32
|
+
# codegraph
|
|
33
|
+
|
|
34
|
+
**Cached, incremental code-graph maps so AI agents query code structure instead of reading whole files.**
|
|
35
|
+
|
|
36
|
+
Agents burn most of their tokens reading source files to answer structural questions — "where is this defined?", "who calls this?", "what does this file import?". `codegraph` answers those questions from a cached tree-sitter AST graph in milliseconds, so the agent reads only the exact line ranges it needs.
|
|
37
|
+
|
|
38
|
+
```
|
|
39
|
+
$ codegraph sym cli_fallback
|
|
40
|
+
CLIFallback [class] agent/cli_fallback.py:24-210
|
|
41
|
+
run_cascade [function] agent/cli_fallback.py:96-158
|
|
42
|
+
|
|
43
|
+
$ codegraph callers run_cascade
|
|
44
|
+
handle_turn [function] agent/loop.py:311-360
|
|
45
|
+
retry_turn [function] agent/loop.py:402-431
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
One `build` per repo; after that, every query auto-refreshes only the files that changed since the last call (mtime-based). No daemon, no database, no API keys — a JSON cache under `~/.cache/codegraph`.
|
|
49
|
+
|
|
50
|
+
## Install
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
pip install codegraph-kit # CLI
|
|
54
|
+
pip install "codegraph-kit[mcp]" # CLI + MCP server
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Python ≥ 3.10. Parsing is done by [graphify](https://pypi.org/project/graphifyy/) (tree-sitter), which ships wheels for Python, JavaScript, TypeScript, Go, Rust, Java, Ruby, C, C++, C#, PHP, Swift, Kotlin, shell, and more.
|
|
58
|
+
|
|
59
|
+
## Commands
|
|
60
|
+
|
|
61
|
+
| Command | What it answers |
|
|
62
|
+
|---|---|
|
|
63
|
+
| `codegraph build [dir]` | full graph build (run once per repo) |
|
|
64
|
+
| `codegraph map [dir]` | repo overview: size, per-directory breakdown, top hub symbols |
|
|
65
|
+
| `codegraph file <path>` | outline of one file: definitions + line ranges |
|
|
66
|
+
| `codegraph sym <name>` | where is this symbol defined? |
|
|
67
|
+
| `codegraph callers <name>` | who calls it? |
|
|
68
|
+
| `codegraph callees <name>` | what does it call? |
|
|
69
|
+
| `codegraph deps <path>` | what does this file import? |
|
|
70
|
+
| `codegraph ensure [dir]` | incremental refresh (queries do this automatically) |
|
|
71
|
+
| `codegraph touch <path>` | re-extract one file (for editor/agent hooks) |
|
|
72
|
+
| `codegraph agent` | print an instruction snippet for your agent's context file |
|
|
73
|
+
| `codegraph mcp` | run as an MCP server (stdio) |
|
|
74
|
+
|
|
75
|
+
## Integrate with any agent
|
|
76
|
+
|
|
77
|
+
`codegraph` is plain CLI-over-stdout, so **any agent that can run shell commands can use it** — Claude Code, Codex CLI, Cursor, Aider, OpenHands, Goose, custom agents. Two steps:
|
|
78
|
+
|
|
79
|
+
**1. Tell the agent the graph exists.** Append the ready-made snippet to your agent's context file:
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
codegraph agent >> AGENTS.md # or CLAUDE.md, .cursorrules, .github/copilot-instructions.md
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
**2. (Optional) Keep the graph fresh on every edit.** For Claude Code, install the bundled PostToolUse hook so each `Edit`/`Write` re-extracts just that file:
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
cp integrations/claude-code/codegraph-touch.sh ~/.claude/hooks/
|
|
89
|
+
chmod +x ~/.claude/hooks/codegraph-touch.sh
|
|
90
|
+
# then merge integrations/claude-code/settings-snippet.json into ~/.claude/settings.json
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Even without a hook, queries stay correct: every query runs an mtime check first and re-extracts anything stale.
|
|
94
|
+
|
|
95
|
+
### MCP (for agents that don't shell out)
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
pip install "codegraph-kit[mcp]"
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Register `codegraph mcp` as a stdio server. For Claude Code:
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
claude mcp add codegraph -- codegraph mcp
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Tools exposed: `build_graph`, `graph_map`, `file_outline`, `find_symbol`, `callers`, `callees`, `file_deps` — same output as the CLI.
|
|
108
|
+
|
|
109
|
+
## Why not just let the agent read files?
|
|
110
|
+
|
|
111
|
+
Reading a 1,500-line file to find one function costs ~15k tokens; `codegraph file` returns the outline in ~200 tokens, and the agent then reads only the 40-line range it needs. On large repos the difference compounds — structural questions (symbol lookup, call tracing, import mapping) stop costing file-reads entirely.
|
|
112
|
+
|
|
113
|
+
Honest limitations, printed in the output when they apply:
|
|
114
|
+
|
|
115
|
+
- **Dynamic dispatch isn't captured** — call edges come from static AST analysis; `getattr`-style calls need grep.
|
|
116
|
+
- **Unsupported/exotic languages** fall back to "read it directly".
|
|
117
|
+
- Caps: 5,000 files per repo, 1 MB per file (warned, not silent).
|
|
118
|
+
|
|
119
|
+
## How it works
|
|
120
|
+
|
|
121
|
+
1. `build` walks the repo (skipping `node_modules`, `venv`, `dist`, …), runs tree-sitter extraction via graphify, normalizes all paths root-relative, and writes `graph.json` + an mtime index to `~/.cache/codegraph/<repo-hash>/`.
|
|
122
|
+
2. Every query calls `ensure` first: files whose mtime changed are re-extracted and spliced into the graph; deleted files are dropped. Typical refresh is a handful of files, so queries stay fast.
|
|
123
|
+
3. Output is deliberately plain text with `file:line` locations — clickable in most agent UIs and trivially parseable.
|
|
124
|
+
|
|
125
|
+
Set `CODEGRAPH_CACHE` to relocate the cache (useful in CI and sandboxes).
|
|
126
|
+
|
|
127
|
+
## License
|
|
128
|
+
|
|
129
|
+
MIT
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
codegraph_kit/__init__.py,sha256=hvT298W9v1mmnYe6HKgi4U1MfRNt6u7-aBa4nW2Th4k,140
|
|
2
|
+
codegraph_kit/cli.py,sha256=Q70kSZp5LLigQVrukoWg6bRDN7ixJQzeQ7cPxkadUOU,4042
|
|
3
|
+
codegraph_kit/core.py,sha256=QrGDrZc32ODJt6HFj4TedxrGJkwJcvyc7Br7wnEKqW8,6359
|
|
4
|
+
codegraph_kit/mcp_server.py,sha256=mICw1h5P9oiwEg1LWYTfi7j8Y8ow9zbtFd_cryc1bVE,2494
|
|
5
|
+
codegraph_kit/queries.py,sha256=_1E6KhyBhfxE1-BVxwIBbthSFFoeBoDKcn9OT97WS98,3164
|
|
6
|
+
codegraph_kit-0.1.0.dist-info/METADATA,sha256=LCFlHepudvDuCNLE-vL-29JMj8GH9j7T37TEwSZjeNY,6071
|
|
7
|
+
codegraph_kit-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
8
|
+
codegraph_kit-0.1.0.dist-info/entry_points.txt,sha256=0h9B03fi5q4ApHocwXm6W6fbzwZno_Z7cf-W6HW-PSE,53
|
|
9
|
+
codegraph_kit-0.1.0.dist-info/licenses/LICENSE,sha256=bIcrUqZy4o04kz-4Awxz8HWXHjdwL0YlBZ-GhsuQqgk,1067
|
|
10
|
+
codegraph_kit-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Duc Nguyen
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|