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.
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
+ )