codecompass-mcp 2.0.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.
- codecompass_mcp-2.0.0.dist-info/METADATA +368 -0
- codecompass_mcp-2.0.0.dist-info/RECORD +28 -0
- codecompass_mcp-2.0.0.dist-info/WHEEL +5 -0
- codecompass_mcp-2.0.0.dist-info/entry_points.txt +3 -0
- codecompass_mcp-2.0.0.dist-info/licenses/LICENSE +21 -0
- codecompass_mcp-2.0.0.dist-info/top_level.txt +6 -0
- config.py +16 -0
- graph/__init__.py +0 -0
- graph/cli.py +13 -0
- graph/code_graph_client.py +485 -0
- graph/code_query_cli.py +504 -0
- graph/mcp_server.py +280 -0
- graph/setup.py +255 -0
- ingestion/__init__.py +0 -0
- ingestion/chunker.py +70 -0
- ingestion/code_normalizer.py +158 -0
- ingestion/code_parser.py +709 -0
- ingestion/entity_resolver.py +179 -0
- ingestion/file_watcher.py +165 -0
- ingestion/graph_writer.py +17 -0
- ingestion/hierarchy_builder.py +148 -0
- ingestion/reader_agent.py +135 -0
- main.py +306 -0
- models/__init__.py +0 -0
- models/code_types.py +35 -0
- models/types.py +45 -0
- utils/__init__.py +0 -0
- utils/formatting.py +24 -0
main.py
ADDED
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
"""CodeCompass — code dependency index for LLM coding agents.
|
|
2
|
+
|
|
3
|
+
Commands:
|
|
4
|
+
ingest-code <repo_path> --project <name> [--normalize] [--dump-triples <out.json>]
|
|
5
|
+
load-triples <triples.json> --project <name>
|
|
6
|
+
watch <repo_path> --project <name>
|
|
7
|
+
dedupe-edges [--dry-run]
|
|
8
|
+
setup
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import sys
|
|
12
|
+
from dotenv import load_dotenv
|
|
13
|
+
from rich.console import Console
|
|
14
|
+
|
|
15
|
+
from graph.code_graph_client import get_client
|
|
16
|
+
from ingestion.code_parser import parse_directory
|
|
17
|
+
from ingestion.hierarchy_builder import build_hierarchy, get_file_id_map
|
|
18
|
+
from ingestion.file_watcher import FileWatcher
|
|
19
|
+
|
|
20
|
+
load_dotenv(override=True)
|
|
21
|
+
console = Console()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def ingest_code(repo_path: str, project_name: str, normalize: bool = False, dump_triples: str | None = None) -> None:
|
|
25
|
+
"""Ingest a codebase into the code knowledge graph.
|
|
26
|
+
|
|
27
|
+
Phase 1: Walk the repo and write the Project → Folder → File skeleton.
|
|
28
|
+
Phase 2: Parse every source file with tree-sitter into CodeTriples.
|
|
29
|
+
Phase 3: Normalize entity names via Haiku (only if --normalize is passed).
|
|
30
|
+
Phase 4: Write all triples to the project graph.
|
|
31
|
+
|
|
32
|
+
If dump_triples is given, raw triples are written to that JSON file and
|
|
33
|
+
the ingest stops — use this to normalize externally, then reload with
|
|
34
|
+
load-triples.
|
|
35
|
+
"""
|
|
36
|
+
import os
|
|
37
|
+
import json
|
|
38
|
+
from tqdm import tqdm
|
|
39
|
+
|
|
40
|
+
repo_path = os.path.abspath(repo_path)
|
|
41
|
+
console.print(f"[bold blue]Ingesting codebase:[/] {repo_path}")
|
|
42
|
+
console.print(f"[dim]Project name:[/] {project_name}")
|
|
43
|
+
|
|
44
|
+
client = get_client(project_name)
|
|
45
|
+
client.ensure_indexes()
|
|
46
|
+
|
|
47
|
+
console.print("[dim]Phase 1/4 — Building hierarchy…[/]")
|
|
48
|
+
file_id_map = build_hierarchy(repo_path, project_name, client)
|
|
49
|
+
console.print(f"[dim] {len(file_id_map)} source files indexed[/]")
|
|
50
|
+
|
|
51
|
+
console.print("[dim]Phase 2/4 — Parsing source files…[/]")
|
|
52
|
+
raw_triples = parse_directory(repo_path, progress=True)
|
|
53
|
+
console.print(f"[dim] {len(raw_triples)} raw triples extracted[/]")
|
|
54
|
+
|
|
55
|
+
if not raw_triples:
|
|
56
|
+
console.print("[yellow]No triples extracted — check that the repo contains supported files.[/]")
|
|
57
|
+
client.close()
|
|
58
|
+
return
|
|
59
|
+
|
|
60
|
+
if dump_triples:
|
|
61
|
+
data = [
|
|
62
|
+
{
|
|
63
|
+
"from_entity": t.from_entity,
|
|
64
|
+
"from_type": t.from_type,
|
|
65
|
+
"relation_type": t.relation_type,
|
|
66
|
+
"to_entity": t.to_entity,
|
|
67
|
+
"to_type": t.to_type,
|
|
68
|
+
"source_file": t.source_file,
|
|
69
|
+
"line_number": t.line_number,
|
|
70
|
+
}
|
|
71
|
+
for t in raw_triples
|
|
72
|
+
]
|
|
73
|
+
with open(dump_triples, "w") as f:
|
|
74
|
+
json.dump(data, f, indent=2)
|
|
75
|
+
client.close()
|
|
76
|
+
console.print(f"[bold green]Dumped {len(raw_triples)} raw triples to:[/] {dump_triples}")
|
|
77
|
+
console.print("[dim]Normalize them, then run: codecompass load-triples <file> --project <name>[/]")
|
|
78
|
+
return
|
|
79
|
+
|
|
80
|
+
if normalize:
|
|
81
|
+
from ingestion.code_normalizer import normalize_triples
|
|
82
|
+
console.print("[dim]Phase 3/4 — Normalizing triples via Haiku…[/]")
|
|
83
|
+
triples = normalize_triples(raw_triples, progress=True)
|
|
84
|
+
console.print(f"[dim] {len(triples)} triples after normalization[/]")
|
|
85
|
+
else:
|
|
86
|
+
console.print("[dim]Phase 3/4 — Skipping normalization (pass --normalize to enable)[/]")
|
|
87
|
+
triples = raw_triples
|
|
88
|
+
|
|
89
|
+
console.print("[dim]Phase 4/4 — Writing to Neo4j…[/]")
|
|
90
|
+
written = client.write_code_triples_batch(triples, file_id_map, project_name)
|
|
91
|
+
|
|
92
|
+
total_nodes = client.node_count()
|
|
93
|
+
client.close()
|
|
94
|
+
|
|
95
|
+
console.print(
|
|
96
|
+
f"[bold green]Done.[/] Wrote {written} triples. "
|
|
97
|
+
f"Graph now has {total_nodes} nodes."
|
|
98
|
+
)
|
|
99
|
+
_register_project_agents_md(repo_path, project_name)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
_CODECOMPASS_START = "<!-- codecompass-code-graph-start -->"
|
|
103
|
+
_CODECOMPASS_END = "<!-- codecompass-code-graph-end -->"
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _register_project_agents_md(repo_path: str, project_name: str) -> None:
|
|
107
|
+
"""Write or update the Code graph section in the project's AGENTS.md.
|
|
108
|
+
|
|
109
|
+
Uses HTML comment markers so re-ingesting safely replaces only that block.
|
|
110
|
+
"""
|
|
111
|
+
import os, re
|
|
112
|
+
|
|
113
|
+
block = (
|
|
114
|
+
f"{_CODECOMPASS_START}\n"
|
|
115
|
+
f"## Code graph\n\n"
|
|
116
|
+
f"This project is indexed in the CodeCompass code graph as `{project_name}`. "
|
|
117
|
+
f"Query it before editing to know what to read:\n\n"
|
|
118
|
+
f"```bash\n"
|
|
119
|
+
f"# Run from your codecompass install directory:\n"
|
|
120
|
+
f"codecompass --deps <file> --project {project_name}\n"
|
|
121
|
+
f"codecompass --impact \"<function>\" --project {project_name}\n"
|
|
122
|
+
f"codecompass --tree {project_name}\n"
|
|
123
|
+
f"```\n\n"
|
|
124
|
+
f"Re-ingest after adding files:\n"
|
|
125
|
+
f"```bash\n"
|
|
126
|
+
f"codecompass ingest-code {repo_path} --project {project_name}\n"
|
|
127
|
+
f"```\n"
|
|
128
|
+
f"{_CODECOMPASS_END}"
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
agents_md_path = os.path.join(repo_path, "AGENTS.md")
|
|
132
|
+
|
|
133
|
+
if os.path.exists(agents_md_path):
|
|
134
|
+
with open(agents_md_path) as f:
|
|
135
|
+
content = f.read()
|
|
136
|
+
if _CODECOMPASS_START in content:
|
|
137
|
+
pattern = re.escape(_CODECOMPASS_START) + r".*?" + re.escape(_CODECOMPASS_END)
|
|
138
|
+
new_content = re.sub(pattern, block, content, flags=re.DOTALL)
|
|
139
|
+
else:
|
|
140
|
+
new_content = content.rstrip() + f"\n\n---\n\n{block}\n"
|
|
141
|
+
else:
|
|
142
|
+
new_content = block + "\n"
|
|
143
|
+
|
|
144
|
+
with open(agents_md_path, "w") as f:
|
|
145
|
+
f.write(new_content)
|
|
146
|
+
|
|
147
|
+
console.print(f"[dim] Registered in {agents_md_path}[/]")
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def load_triples(triples_file: str, project_name: str) -> None:
|
|
151
|
+
"""Load pre-normalized triples from a JSON file into the code graph."""
|
|
152
|
+
import json
|
|
153
|
+
from models.code_types import CodeTriple
|
|
154
|
+
|
|
155
|
+
with open(triples_file) as f:
|
|
156
|
+
data = json.load(f)
|
|
157
|
+
|
|
158
|
+
triples = [
|
|
159
|
+
CodeTriple(
|
|
160
|
+
from_entity=d["from_entity"],
|
|
161
|
+
from_type=d["from_type"],
|
|
162
|
+
relation_type=d["relation_type"],
|
|
163
|
+
to_entity=d["to_entity"],
|
|
164
|
+
to_type=d["to_type"],
|
|
165
|
+
source_file=d["source_file"],
|
|
166
|
+
line_number=d["line_number"],
|
|
167
|
+
)
|
|
168
|
+
for d in data
|
|
169
|
+
]
|
|
170
|
+
|
|
171
|
+
console.print(f"[bold blue]Loading {len(triples)} triples from:[/] {triples_file}")
|
|
172
|
+
client = get_client(project_name)
|
|
173
|
+
file_id_map = get_file_id_map(project_name, client)
|
|
174
|
+
|
|
175
|
+
written = client.write_code_triples_batch(triples, file_id_map, project_name)
|
|
176
|
+
|
|
177
|
+
total_nodes = client.node_count()
|
|
178
|
+
client.close()
|
|
179
|
+
console.print(f"[bold green]Done.[/] Wrote {written} triples. Graph now has {total_nodes} nodes.")
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def watch_code(repo_path: str, project_name: str) -> None:
|
|
183
|
+
"""Watch a repo for file changes and keep the code graph updated incrementally."""
|
|
184
|
+
import os
|
|
185
|
+
repo_path = os.path.abspath(repo_path)
|
|
186
|
+
client = get_client(project_name)
|
|
187
|
+
file_id_map = build_hierarchy(repo_path, project_name, client)
|
|
188
|
+
watcher = FileWatcher(repo_path, project_name, client, file_id_map)
|
|
189
|
+
watcher.start()
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def dedupe_edges(dry_run: bool = False) -> None:
|
|
193
|
+
"""Remove duplicate RELATION edges (same from-node, type, to-node)."""
|
|
194
|
+
from config import neo4j_config
|
|
195
|
+
from neo4j import GraphDatabase
|
|
196
|
+
|
|
197
|
+
cfg = neo4j_config()
|
|
198
|
+
driver = GraphDatabase.driver(cfg["uri"], auth=(cfg["user"], cfg["password"]))
|
|
199
|
+
|
|
200
|
+
with driver.session() as session:
|
|
201
|
+
result = session.run("""
|
|
202
|
+
MATCH (a)-[r:RELATION]->(b)
|
|
203
|
+
WITH a, r.type AS rel_type, b, collect(r) AS rels
|
|
204
|
+
WHERE size(rels) > 1
|
|
205
|
+
RETURN count(*) AS dup_groups, sum(size(rels) - 1) AS removable
|
|
206
|
+
""")
|
|
207
|
+
row = result.single()
|
|
208
|
+
dup_groups = row["dup_groups"]
|
|
209
|
+
removable = row["removable"]
|
|
210
|
+
|
|
211
|
+
if removable == 0:
|
|
212
|
+
console.print("[dim]No duplicate edges found.[/]")
|
|
213
|
+
driver.close()
|
|
214
|
+
return
|
|
215
|
+
|
|
216
|
+
console.print(f"[dim]Found {dup_groups} duplicate groups, {removable} removable edges.[/]")
|
|
217
|
+
|
|
218
|
+
if dry_run:
|
|
219
|
+
console.print("[dim]Dry run — nothing deleted.[/]")
|
|
220
|
+
else:
|
|
221
|
+
session.run("""
|
|
222
|
+
MATCH (a)-[r:RELATION]->(b)
|
|
223
|
+
WITH a, r.type AS rel_type, b, collect(r) AS rels
|
|
224
|
+
WHERE size(rels) > 1
|
|
225
|
+
FOREACH (r IN tail(rels) | DELETE r)
|
|
226
|
+
""")
|
|
227
|
+
console.print(f"[bold green]Done.[/] Removed {removable} duplicate edge(s).")
|
|
228
|
+
|
|
229
|
+
driver.close()
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def main():
|
|
233
|
+
prog = "codecompass"
|
|
234
|
+
if len(sys.argv) < 2:
|
|
235
|
+
console.print("[bold]Usage:[/]")
|
|
236
|
+
console.print(f" {prog} ingest-code [italic]<repo_path>[/] --project [italic]<name>[/]")
|
|
237
|
+
console.print(f" {prog} ingest-code [italic]<repo_path>[/] --project [italic]<name>[/] --normalize")
|
|
238
|
+
console.print(f" {prog} ingest-code [italic]<repo_path>[/] --project [italic]<name>[/] --dump-triples [italic]<out.json>[/]")
|
|
239
|
+
console.print(f" {prog} load-triples [italic]<triples.json>[/] --project [italic]<name>[/]")
|
|
240
|
+
console.print(f" {prog} watch [italic]<repo_path>[/] --project [italic]<name>[/]")
|
|
241
|
+
console.print(f" {prog} dedupe-edges [italic][--dry-run][/]")
|
|
242
|
+
console.print(f" {prog} setup")
|
|
243
|
+
sys.exit(1)
|
|
244
|
+
|
|
245
|
+
command = sys.argv[1]
|
|
246
|
+
|
|
247
|
+
if command == "ingest-code":
|
|
248
|
+
args = sys.argv[2:]
|
|
249
|
+
if not args:
|
|
250
|
+
console.print(f"[red]Usage: {prog} ingest-code <repo_path> --project <name>[/]")
|
|
251
|
+
sys.exit(1)
|
|
252
|
+
repo_path = args[0]
|
|
253
|
+
project_name = "default"
|
|
254
|
+
normalize = "--normalize" in args
|
|
255
|
+
dump_triples = None
|
|
256
|
+
if "--dump-triples" in args:
|
|
257
|
+
idx = args.index("--dump-triples")
|
|
258
|
+
if idx + 1 < len(args):
|
|
259
|
+
dump_triples = args[idx + 1]
|
|
260
|
+
if "--project" in args:
|
|
261
|
+
idx = args.index("--project")
|
|
262
|
+
if idx + 1 < len(args):
|
|
263
|
+
project_name = args[idx + 1]
|
|
264
|
+
ingest_code(repo_path, project_name, normalize=normalize, dump_triples=dump_triples)
|
|
265
|
+
|
|
266
|
+
elif command == "load-triples":
|
|
267
|
+
args = sys.argv[2:]
|
|
268
|
+
if not args:
|
|
269
|
+
console.print(f"[red]Usage: {prog} load-triples <triples.json> --project <name>[/]")
|
|
270
|
+
sys.exit(1)
|
|
271
|
+
triples_file = args[0]
|
|
272
|
+
project_name = "default"
|
|
273
|
+
if "--project" in args:
|
|
274
|
+
idx = args.index("--project")
|
|
275
|
+
if idx + 1 < len(args):
|
|
276
|
+
project_name = args[idx + 1]
|
|
277
|
+
load_triples(triples_file, project_name)
|
|
278
|
+
|
|
279
|
+
elif command == "watch":
|
|
280
|
+
args = sys.argv[2:]
|
|
281
|
+
if not args:
|
|
282
|
+
console.print(f"[red]Usage: {prog} watch <repo_path> --project <name>[/]")
|
|
283
|
+
sys.exit(1)
|
|
284
|
+
repo_path = args[0]
|
|
285
|
+
project_name = "default"
|
|
286
|
+
if "--project" in args:
|
|
287
|
+
idx = args.index("--project")
|
|
288
|
+
if idx + 1 < len(args):
|
|
289
|
+
project_name = args[idx + 1]
|
|
290
|
+
watch_code(repo_path, project_name)
|
|
291
|
+
|
|
292
|
+
elif command == "dedupe-edges":
|
|
293
|
+
dry_run = "--dry-run" in sys.argv[2:]
|
|
294
|
+
dedupe_edges(dry_run=dry_run)
|
|
295
|
+
|
|
296
|
+
elif command == "setup":
|
|
297
|
+
from graph.setup import run_setup
|
|
298
|
+
run_setup()
|
|
299
|
+
|
|
300
|
+
else:
|
|
301
|
+
console.print(f"[red]Unknown command:[/] {command}")
|
|
302
|
+
sys.exit(1)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
if __name__ == "__main__":
|
|
306
|
+
main()
|
models/__init__.py
ADDED
|
File without changes
|
models/code_types.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass
|
|
7
|
+
class CodeTriple:
|
|
8
|
+
"""A single typed relationship extracted from source code."""
|
|
9
|
+
|
|
10
|
+
from_entity: str
|
|
11
|
+
from_type: str # function | class | module | css_selector | html_element | scss_mixin | scss_variable
|
|
12
|
+
relation_type: str # CALLS | IMPORTS | INHERITS | DEFINED_IN | STYLES | HAS_CLASS | POSTS_TO | INCLUDES | USED_BY
|
|
13
|
+
to_entity: str
|
|
14
|
+
to_type: str
|
|
15
|
+
source_file: str # relative path from project root
|
|
16
|
+
line_number: int
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class FileNode:
|
|
21
|
+
"""Leaf node in the project hierarchy — a single source file."""
|
|
22
|
+
|
|
23
|
+
path: str # relative path from project root
|
|
24
|
+
name: str # filename without directory
|
|
25
|
+
extension: str # e.g. ".py", ".ts"
|
|
26
|
+
depth: int # nesting depth from project root (root files = 1)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class FolderNode:
|
|
31
|
+
"""Intermediate node in the project hierarchy."""
|
|
32
|
+
|
|
33
|
+
path: str # relative path from project root
|
|
34
|
+
name: str # folder name
|
|
35
|
+
depth: int
|
models/types.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
from typing import Optional
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
@dataclass
|
|
6
|
+
class Entity:
|
|
7
|
+
id: str
|
|
8
|
+
name: str
|
|
9
|
+
type: str # Person, Concept, Place, Event, etc.
|
|
10
|
+
description: Optional[str] = None
|
|
11
|
+
source_chunk: Optional[str] = None
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class Relation:
|
|
16
|
+
from_id: str
|
|
17
|
+
to_id: str
|
|
18
|
+
type: str # HAS_COMPONENT, CAUSES, DEPENDS_ON, etc.
|
|
19
|
+
weight: float = 1.0 # Confidence/strength 0–1
|
|
20
|
+
description: Optional[str] = None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class Triple:
|
|
25
|
+
entity_from: Entity
|
|
26
|
+
relation: Relation
|
|
27
|
+
entity_to: Entity
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class TraversalStep:
|
|
32
|
+
node_id: str
|
|
33
|
+
node_name: str
|
|
34
|
+
relation_type: str
|
|
35
|
+
relevance_score: float
|
|
36
|
+
reasoning: str # Why the agent followed this edge
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class QueryResult:
|
|
41
|
+
answer: str
|
|
42
|
+
reasoning_path: list[TraversalStep]
|
|
43
|
+
nodes_explored: int
|
|
44
|
+
nodes_retrieved: int
|
|
45
|
+
hops_taken: int
|
utils/__init__.py
ADDED
|
File without changes
|
utils/formatting.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from models.types import TraversalStep
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def format_traversal_steps(steps: list[TraversalStep]) -> str:
|
|
5
|
+
"""Human-readable summary of a reasoning path"""
|
|
6
|
+
if not steps:
|
|
7
|
+
return "(no traversal steps)"
|
|
8
|
+
lines = []
|
|
9
|
+
for i, step in enumerate(steps, 1):
|
|
10
|
+
score = f"{step.relevance_score:.2f}"
|
|
11
|
+
lines.append(
|
|
12
|
+
f" {i}. [{step.relation_type}] → {step.node_name} "
|
|
13
|
+
f"(score={score}): {step.reasoning}"
|
|
14
|
+
)
|
|
15
|
+
return "\n".join(lines)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def format_subgraph_rows(rows: list[dict]) -> str:
|
|
19
|
+
"""Format Neo4j subgraph rows for LLM prompts"""
|
|
20
|
+
return "\n".join(
|
|
21
|
+
f"({row['n.name']}) --[{row['r.type']}]--> ({row['m.name']})"
|
|
22
|
+
+ (f": {row['r.description']}" if row.get("r.description") else "")
|
|
23
|
+
for row in rows
|
|
24
|
+
)
|