codecortex 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.
Potentially problematic release.
This version of codecortex might be problematic. Click here for more details.
- codecortex-0.2.0.dist-info/METADATA +206 -0
- codecortex-0.2.0.dist-info/RECORD +31 -0
- codecortex-0.2.0.dist-info/WHEEL +5 -0
- codecortex-0.2.0.dist-info/entry_points.txt +2 -0
- codecortex-0.2.0.dist-info/licenses/LICENSE +21 -0
- codecortex-0.2.0.dist-info/top_level.txt +1 -0
- codeintel/__init__.py +1 -0
- codeintel/__main__.py +361 -0
- codeintel/cache.py +66 -0
- codeintel/config.py +42 -0
- codeintel/doctor.py +161 -0
- codeintel/gateway.py +228 -0
- codeintel/http_server.py +93 -0
- codeintel/indexer.py +250 -0
- codeintel/injector.py +81 -0
- codeintel/installer.py +103 -0
- codeintel/mapper.py +192 -0
- codeintel/onboarding.py +197 -0
- codeintel/policy.py +30 -0
- codeintel/provider.py +52 -0
- codeintel/providers/__init__.py +0 -0
- codeintel/providers/graph.py +415 -0
- codeintel/providers/lsp.py +407 -0
- codeintel/providers/none.py +30 -0
- codeintel/providers/semantic.py +139 -0
- codeintel/reindexer.py +112 -0
- codeintel/reset.py +100 -0
- codeintel/searcher.py +143 -0
- codeintel/semantic_db.py +78 -0
- codeintel/server.py +216 -0
- codeintel/term.py +162 -0
codeintel/indexer.py
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
import struct
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import TYPE_CHECKING
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from codeintel.semantic_db import SemanticDb
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
_INDEXED_EXTS = frozenset({
|
|
16
|
+
".py", ".ts", ".js", ".go", ".rs", ".java", ".c", ".cpp", ".h", ".md"
|
|
17
|
+
})
|
|
18
|
+
_SKIP_DIRS = frozenset({"__pycache__", ".git", "node_modules"})
|
|
19
|
+
# Vendored / regenerable dirs skipped even without a .gitignore entry.
|
|
20
|
+
_DEFAULT_IGNORES = frozenset({
|
|
21
|
+
".venv", "venv", "env", "dist", "build", "target",
|
|
22
|
+
".mypy_cache", ".pytest_cache", ".tox", ".idea", ".vscode", ".cache",
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _project_key(project_root_real: str) -> str:
|
|
27
|
+
"""A short, stable id for a project root — prefixes every chunk_id so two repos
|
|
28
|
+
with an identically-named file never collide in the shared cache."""
|
|
29
|
+
return hashlib.sha256(project_root_real.encode()).hexdigest()[:12]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Indexer:
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
db: SemanticDb,
|
|
36
|
+
model_name: str = "BAAI/bge-small-en-v1.5",
|
|
37
|
+
window: int = 20,
|
|
38
|
+
stride: int = 10,
|
|
39
|
+
max_chunks: int = 500,
|
|
40
|
+
) -> None:
|
|
41
|
+
self.db = db
|
|
42
|
+
self.model_name = model_name
|
|
43
|
+
self.window = window
|
|
44
|
+
self.stride = stride
|
|
45
|
+
self.max_chunks = max_chunks
|
|
46
|
+
self._embedder = None
|
|
47
|
+
|
|
48
|
+
def _get_embedder(self):
|
|
49
|
+
if self._embedder is None:
|
|
50
|
+
from fastembed import TextEmbedding
|
|
51
|
+
self._embedder = TextEmbedding(model_name=self.model_name)
|
|
52
|
+
return self._embedder
|
|
53
|
+
|
|
54
|
+
def index(self, project_root: str) -> int:
|
|
55
|
+
"""Return count of newly embedded chunks, or -1 on unrecoverable failure."""
|
|
56
|
+
try:
|
|
57
|
+
return self._index(project_root)
|
|
58
|
+
except Exception as exc:
|
|
59
|
+
logger.error("Indexer.index() unrecoverable failure: %s", exc)
|
|
60
|
+
return -1
|
|
61
|
+
|
|
62
|
+
def _load_gitignore(self, root: Path) -> set[str]:
|
|
63
|
+
"""Best-effort ``.gitignore``: collect simple name/dir patterns to skip. This is
|
|
64
|
+
NOT full gitignore semantics (no globs, negations, or nesting) — just enough to
|
|
65
|
+
avoid indexing vendored/build output the user already told git to ignore."""
|
|
66
|
+
patterns: set[str] = set()
|
|
67
|
+
gi = root / ".gitignore"
|
|
68
|
+
try:
|
|
69
|
+
if gi.is_file():
|
|
70
|
+
for line in gi.read_text(encoding="utf-8", errors="replace").splitlines():
|
|
71
|
+
line = line.strip()
|
|
72
|
+
if not line or line.startswith("#") or line.startswith("!"):
|
|
73
|
+
continue
|
|
74
|
+
name = line.rstrip("/").lstrip("/")
|
|
75
|
+
if name and "*" not in name and "/" not in name:
|
|
76
|
+
patterns.add(name)
|
|
77
|
+
except Exception:
|
|
78
|
+
pass
|
|
79
|
+
return patterns
|
|
80
|
+
|
|
81
|
+
def _cleanup_deleted(self, root: Path, project_root_real: str) -> None:
|
|
82
|
+
"""Drop rows for THIS project whose file no longer exists — scoped by
|
|
83
|
+
project_root so touching one repo can never purge another's index."""
|
|
84
|
+
conn = self.db.conn()
|
|
85
|
+
try:
|
|
86
|
+
rows = conn.execute(
|
|
87
|
+
"SELECT DISTINCT file_path FROM chunk_hashes WHERE project_root = ?",
|
|
88
|
+
(project_root_real,),
|
|
89
|
+
).fetchall()
|
|
90
|
+
deleted_paths = [
|
|
91
|
+
row[0] for row in rows if not (root / row[0]).exists()
|
|
92
|
+
]
|
|
93
|
+
for fp in deleted_paths:
|
|
94
|
+
chunk_ids = [
|
|
95
|
+
r[0]
|
|
96
|
+
for r in conn.execute(
|
|
97
|
+
"SELECT chunk_id FROM chunk_hashes"
|
|
98
|
+
" WHERE project_root = ? AND file_path = ?",
|
|
99
|
+
(project_root_real, fp),
|
|
100
|
+
).fetchall()
|
|
101
|
+
]
|
|
102
|
+
for cid in chunk_ids:
|
|
103
|
+
conn.execute(
|
|
104
|
+
"DELETE FROM code_embeddings WHERE chunk_id = ?", (cid,)
|
|
105
|
+
)
|
|
106
|
+
conn.execute(
|
|
107
|
+
"DELETE FROM chunk_hashes WHERE chunk_id = ?", (cid,)
|
|
108
|
+
)
|
|
109
|
+
conn.commit()
|
|
110
|
+
except Exception as exc:
|
|
111
|
+
logger.warning("Cleanup pass failed: %s", exc)
|
|
112
|
+
|
|
113
|
+
def _walk_files(self, root: Path):
|
|
114
|
+
ignores = set(_SKIP_DIRS) | set(_DEFAULT_IGNORES) | self._load_gitignore(root)
|
|
115
|
+
for dirpath, dirnames, filenames in os.walk(root):
|
|
116
|
+
dirnames[:] = [
|
|
117
|
+
d for d in dirnames
|
|
118
|
+
if d not in ignores and not d.endswith(".egg-info")
|
|
119
|
+
]
|
|
120
|
+
for fname in filenames:
|
|
121
|
+
if fname in ignores:
|
|
122
|
+
continue
|
|
123
|
+
if Path(fname).suffix.lower() in _INDEXED_EXTS:
|
|
124
|
+
yield Path(dirpath) / fname
|
|
125
|
+
|
|
126
|
+
def _collect_new_chunks(
|
|
127
|
+
self, root: Path, project_key: str, project_root_real: str
|
|
128
|
+
) -> list[tuple[str, str, str, int, str]]:
|
|
129
|
+
"""Walk files; return (chunk_id, text, rel_path, start, hash) for new/changed chunks."""
|
|
130
|
+
conn = self.db.conn()
|
|
131
|
+
new_chunks: list[tuple[str, str, str, int, str]] = []
|
|
132
|
+
|
|
133
|
+
for filepath in self._walk_files(root):
|
|
134
|
+
try:
|
|
135
|
+
with open(filepath, encoding="utf-8", errors="replace") as f:
|
|
136
|
+
lines = f.readlines()
|
|
137
|
+
except FileNotFoundError:
|
|
138
|
+
logger.debug("file disappeared: %s", filepath)
|
|
139
|
+
continue
|
|
140
|
+
except Exception as exc:
|
|
141
|
+
logger.debug("skipping %s: %s", filepath, exc)
|
|
142
|
+
continue
|
|
143
|
+
|
|
144
|
+
rel_path = str(filepath.relative_to(root))
|
|
145
|
+
chunk_count = 0
|
|
146
|
+
|
|
147
|
+
for chunk_start in range(0, len(lines), self.stride):
|
|
148
|
+
if chunk_count >= self.max_chunks:
|
|
149
|
+
logger.debug(
|
|
150
|
+
"chunk cap hit for %s, truncating at %d",
|
|
151
|
+
rel_path,
|
|
152
|
+
self.max_chunks,
|
|
153
|
+
)
|
|
154
|
+
break
|
|
155
|
+
|
|
156
|
+
chunk_lines = lines[chunk_start: chunk_start + self.window]
|
|
157
|
+
if not chunk_lines:
|
|
158
|
+
break
|
|
159
|
+
|
|
160
|
+
chunk_text = "".join(chunk_lines)
|
|
161
|
+
if not chunk_text.strip():
|
|
162
|
+
# EC3.4: never embed empty/whitespace-only chunks (zero vectors pollute results).
|
|
163
|
+
chunk_count += 1
|
|
164
|
+
continue
|
|
165
|
+
|
|
166
|
+
chunk_id = f"{project_key}:{rel_path}:{chunk_start}"
|
|
167
|
+
content_hash = hashlib.sha256(chunk_text.encode()).hexdigest()[:16]
|
|
168
|
+
|
|
169
|
+
try:
|
|
170
|
+
row = conn.execute(
|
|
171
|
+
"SELECT content_hash FROM chunk_hashes WHERE chunk_id = ?",
|
|
172
|
+
(chunk_id,),
|
|
173
|
+
).fetchone()
|
|
174
|
+
if row and row[0] == content_hash:
|
|
175
|
+
chunk_count += 1
|
|
176
|
+
continue
|
|
177
|
+
except Exception as exc:
|
|
178
|
+
logger.debug("hash check failed for %s: %s", chunk_id, exc)
|
|
179
|
+
|
|
180
|
+
new_chunks.append(
|
|
181
|
+
(chunk_id, chunk_text, rel_path, chunk_start, content_hash)
|
|
182
|
+
)
|
|
183
|
+
chunk_count += 1
|
|
184
|
+
|
|
185
|
+
return new_chunks
|
|
186
|
+
|
|
187
|
+
def _embed_and_write(
|
|
188
|
+
self, new_chunks: list[tuple[str, str, str, int, str]], project_root_real: str
|
|
189
|
+
) -> int:
|
|
190
|
+
embedder = self._get_embedder() # may raise → propagates to index() → returns -1
|
|
191
|
+
conn = self.db.conn()
|
|
192
|
+
embedded_count = 0
|
|
193
|
+
batch_size = 32
|
|
194
|
+
|
|
195
|
+
for i in range(0, len(new_chunks), batch_size):
|
|
196
|
+
batch = new_chunks[i: i + batch_size]
|
|
197
|
+
texts = [c[1] for c in batch]
|
|
198
|
+
|
|
199
|
+
try:
|
|
200
|
+
embeddings = list(embedder.embed(texts))
|
|
201
|
+
except Exception as exc:
|
|
202
|
+
logger.warning("embedding batch %d failed: %s", i // batch_size, exc)
|
|
203
|
+
continue
|
|
204
|
+
|
|
205
|
+
for j, (chunk_id, _, rel_path, chunk_start, content_hash) in enumerate(batch):
|
|
206
|
+
if j >= len(embeddings):
|
|
207
|
+
break
|
|
208
|
+
try:
|
|
209
|
+
vec = embeddings[j]
|
|
210
|
+
vec_bytes = struct.pack(f"{len(vec)}f", *vec)
|
|
211
|
+
conn.execute(
|
|
212
|
+
"INSERT OR REPLACE INTO code_embeddings(chunk_id, embedding)"
|
|
213
|
+
" VALUES (?, ?)",
|
|
214
|
+
(chunk_id, vec_bytes),
|
|
215
|
+
)
|
|
216
|
+
conn.execute(
|
|
217
|
+
"INSERT OR REPLACE INTO chunk_hashes"
|
|
218
|
+
"(chunk_id, project_root, file_path, chunk_start, content_hash)"
|
|
219
|
+
" VALUES (?, ?, ?, ?, ?)",
|
|
220
|
+
(chunk_id, project_root_real, rel_path, chunk_start, content_hash),
|
|
221
|
+
)
|
|
222
|
+
embedded_count += 1
|
|
223
|
+
except Exception as exc:
|
|
224
|
+
logger.warning("writing chunk %s failed: %s", chunk_id, exc)
|
|
225
|
+
|
|
226
|
+
try:
|
|
227
|
+
conn.commit()
|
|
228
|
+
except Exception as exc:
|
|
229
|
+
logger.warning("commit failed after batch %d: %s", i // batch_size, exc)
|
|
230
|
+
|
|
231
|
+
return embedded_count
|
|
232
|
+
|
|
233
|
+
def _index(self, project_root: str) -> int:
|
|
234
|
+
if not project_root:
|
|
235
|
+
return 0
|
|
236
|
+
|
|
237
|
+
root = Path(project_root)
|
|
238
|
+
if not root.exists():
|
|
239
|
+
return 0
|
|
240
|
+
|
|
241
|
+
project_root_real = os.path.realpath(project_root)
|
|
242
|
+
project_key = _project_key(project_root_real)
|
|
243
|
+
|
|
244
|
+
self._cleanup_deleted(root, project_root_real)
|
|
245
|
+
|
|
246
|
+
new_chunks = self._collect_new_chunks(root, project_key, project_root_real)
|
|
247
|
+
if not new_chunks:
|
|
248
|
+
return 0
|
|
249
|
+
|
|
250
|
+
return self._embed_and_write(new_chunks, project_root_real)
|
codeintel/injector.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
|
|
6
|
+
_START_MARKER = "<!-- codeintel-map-start -->"
|
|
7
|
+
_END_MARKER = "<!-- codeintel-map-end -->"
|
|
8
|
+
_CONTEXT_FILES = ["CLAUDE.md", "AGENTS.md"]
|
|
9
|
+
|
|
10
|
+
_BLOCK_CONTENT = (
|
|
11
|
+
"\n## codeintel orientation map\n\n"
|
|
12
|
+
"See [CODE_INTEL.md](CODE_INTEL.md) for a ranked overview of this codebase "
|
|
13
|
+
"modules, key symbols (by call frequency), and entry points. "
|
|
14
|
+
"Refresh with: `codeintel map`."
|
|
15
|
+
"\n"
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
_logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Injector:
|
|
22
|
+
"""Idempotently injects a CODE_INTEL.md reference block into CLAUDE.md or AGENTS.md."""
|
|
23
|
+
|
|
24
|
+
def inject(self, project_root: str) -> tuple[str | None, str]:
|
|
25
|
+
try:
|
|
26
|
+
path = _find_context_file(project_root)
|
|
27
|
+
if path is None:
|
|
28
|
+
return (None, "no-context-file")
|
|
29
|
+
|
|
30
|
+
content = _read_file(path)
|
|
31
|
+
if content is None:
|
|
32
|
+
return (None, "error")
|
|
33
|
+
|
|
34
|
+
new_content, action = _update_block(content)
|
|
35
|
+
_write_file(path, new_content)
|
|
36
|
+
return (path, action)
|
|
37
|
+
except Exception as exc:
|
|
38
|
+
_logger.warning("Injector.inject failed: %s", exc)
|
|
39
|
+
return (None, "error")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _find_context_file(project_root: str) -> str | None:
|
|
43
|
+
for name in _CONTEXT_FILES:
|
|
44
|
+
candidate = os.path.join(project_root, name)
|
|
45
|
+
if os.path.isfile(candidate):
|
|
46
|
+
return candidate
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _read_file(path: str) -> str | None:
|
|
51
|
+
try:
|
|
52
|
+
with open(path, encoding="utf-8", errors="strict") as f:
|
|
53
|
+
return f.read()
|
|
54
|
+
except Exception as exc:
|
|
55
|
+
_logger.warning("Injector: could not read %s: %s", path, exc)
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _write_file(path: str, content: str) -> None:
|
|
60
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
61
|
+
f.write(content)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _update_block(content: str) -> tuple[str, str]:
|
|
65
|
+
has_start = _START_MARKER in content
|
|
66
|
+
has_end = _END_MARKER in content
|
|
67
|
+
|
|
68
|
+
block = _START_MARKER + _BLOCK_CONTENT + _END_MARKER
|
|
69
|
+
|
|
70
|
+
if has_start and has_end:
|
|
71
|
+
start_idx = content.index(_START_MARKER)
|
|
72
|
+
end_idx = content.index(_END_MARKER) + len(_END_MARKER)
|
|
73
|
+
new_content = content[:start_idx] + block + content[end_idx:]
|
|
74
|
+
return (new_content, "updated")
|
|
75
|
+
|
|
76
|
+
# Missing or corrupted (only one marker): append a fresh block
|
|
77
|
+
separator = "\n\n" if not content.endswith("\n\n") else ""
|
|
78
|
+
if content.endswith("\n"):
|
|
79
|
+
separator = "\n"
|
|
80
|
+
new_content = content + separator + block
|
|
81
|
+
return (new_content, "appended")
|
codeintel/installer.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import pathlib
|
|
5
|
+
|
|
6
|
+
_AGENTS = ["claude", "codex", "gemini", "zed"]
|
|
7
|
+
|
|
8
|
+
_CONFIG: dict[str, dict] = {
|
|
9
|
+
"claude": {
|
|
10
|
+
"path": "~/.claude/settings.json",
|
|
11
|
+
"key": ["mcpServers", "codeintel"],
|
|
12
|
+
"value": {"command": "codeintel", "args": ["serve"]},
|
|
13
|
+
},
|
|
14
|
+
"codex": {
|
|
15
|
+
"path": "~/.codex/config.json",
|
|
16
|
+
"key": ["mcpServers", "codeintel"],
|
|
17
|
+
"value": {"command": "codeintel", "args": ["serve"]},
|
|
18
|
+
},
|
|
19
|
+
"gemini": {
|
|
20
|
+
"path": "~/.gemini/settings.json",
|
|
21
|
+
"key": ["mcpServers", "codeintel"],
|
|
22
|
+
"value": {"command": "codeintel", "args": ["serve"]},
|
|
23
|
+
},
|
|
24
|
+
"zed": {
|
|
25
|
+
"path": "~/.config/zed/settings.json",
|
|
26
|
+
"key": ["context_servers", "codeintel"],
|
|
27
|
+
"value": {"command": {"path": "codeintel", "args": ["serve"]}},
|
|
28
|
+
},
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _get_nested(data: dict, keys: list[str]):
|
|
33
|
+
node = data
|
|
34
|
+
for k in keys:
|
|
35
|
+
if not isinstance(node, dict) or k not in node:
|
|
36
|
+
return None
|
|
37
|
+
node = node[k]
|
|
38
|
+
return node
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _set_nested(data: dict, keys: list[str], value) -> None:
|
|
42
|
+
node = data
|
|
43
|
+
for k in keys[:-1]:
|
|
44
|
+
if k not in node or not isinstance(node[k], dict):
|
|
45
|
+
node[k] = {}
|
|
46
|
+
node = node[k]
|
|
47
|
+
node[keys[-1]] = value
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class Installer:
|
|
51
|
+
def register(self, agent: str) -> dict:
|
|
52
|
+
spec = _CONFIG.get(agent)
|
|
53
|
+
if spec is None:
|
|
54
|
+
return {
|
|
55
|
+
"agent": agent,
|
|
56
|
+
"path": "",
|
|
57
|
+
"ok": False,
|
|
58
|
+
"action": "failed",
|
|
59
|
+
"reason": f"unknown agent '{agent}'",
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
config_path = pathlib.Path(spec["path"]).expanduser()
|
|
63
|
+
try:
|
|
64
|
+
if config_path.exists():
|
|
65
|
+
data = json.loads(config_path.read_text(encoding="utf-8"))
|
|
66
|
+
if not isinstance(data, dict):
|
|
67
|
+
data = {}
|
|
68
|
+
else:
|
|
69
|
+
data = {}
|
|
70
|
+
|
|
71
|
+
current = _get_nested(data, spec["key"])
|
|
72
|
+
if current == spec["value"]:
|
|
73
|
+
return {
|
|
74
|
+
"agent": agent,
|
|
75
|
+
"path": str(config_path),
|
|
76
|
+
"ok": True,
|
|
77
|
+
"action": "already",
|
|
78
|
+
"reason": "",
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
82
|
+
_set_nested(data, spec["key"], spec["value"])
|
|
83
|
+
config_path.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
"agent": agent,
|
|
87
|
+
"path": str(config_path),
|
|
88
|
+
"ok": True,
|
|
89
|
+
"action": "registered",
|
|
90
|
+
"reason": "",
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
except Exception as exc:
|
|
94
|
+
return {
|
|
95
|
+
"agent": agent,
|
|
96
|
+
"path": str(config_path),
|
|
97
|
+
"ok": False,
|
|
98
|
+
"action": "failed",
|
|
99
|
+
"reason": str(exc),
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
def register_all(self) -> list[dict]:
|
|
103
|
+
return [self.register(agent) for agent in _AGENTS]
|
codeintel/mapper.py
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from typing import Optional, TYPE_CHECKING
|
|
5
|
+
|
|
6
|
+
if TYPE_CHECKING:
|
|
7
|
+
from codeintel.providers.graph import GraphProvider
|
|
8
|
+
|
|
9
|
+
# Real codebase-memory-mcp contract (verified live): query_graph returns {columns, rows}
|
|
10
|
+
# value-arrays, module-level calls are USAGE (not only CALLS), and nodes carry `is_entry_point`
|
|
11
|
+
# (there is no `fn.type` property — that filter matched nothing, so the old queries were dead).
|
|
12
|
+
_FAN_IN_CYPHER = (
|
|
13
|
+
"MATCH (caller)-[:CALLS|USAGE]->(fn) WHERE fn.name IS NOT NULL "
|
|
14
|
+
"RETURN fn.name, fn.qualified_name, fn.file_path, count(caller) AS in_degree "
|
|
15
|
+
"ORDER BY in_degree DESC LIMIT 40"
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
_ENTRY_CYPHER = (
|
|
19
|
+
"MATCH (fn) WHERE fn.is_entry_point = true "
|
|
20
|
+
"RETURN fn.name, fn.file_path LIMIT 10"
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
# Builtins and the synthetic project/config node are high-fan-in but useless as "symbols".
|
|
24
|
+
_RANK_SKIP_PATHS = frozenset({"<python-builtins>", "pyproject.toml", ""})
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _rows_from(raw: object) -> list[dict]:
|
|
28
|
+
"""Normalize a query_graph response to a list of column->value dicts.
|
|
29
|
+
|
|
30
|
+
Accepts the real ``{"columns": [...], "rows": [[...]]}`` shape AND the legacy list-of-dicts
|
|
31
|
+
shape (used by older mocks); anything else yields ``[]``. Mirrors GraphProvider._query_rows
|
|
32
|
+
so the map generator reads the same reality every other graph op now does."""
|
|
33
|
+
if isinstance(raw, list):
|
|
34
|
+
return [r for r in raw if isinstance(r, dict)]
|
|
35
|
+
if not isinstance(raw, dict):
|
|
36
|
+
return []
|
|
37
|
+
cols, rows = raw.get("columns"), raw.get("rows")
|
|
38
|
+
if not isinstance(cols, list) or not isinstance(rows, list):
|
|
39
|
+
return []
|
|
40
|
+
out: list[dict] = []
|
|
41
|
+
for row in rows:
|
|
42
|
+
if isinstance(row, list):
|
|
43
|
+
out.append({str(cols[i]): row[i] for i in range(min(len(cols), len(row)))})
|
|
44
|
+
elif isinstance(row, dict):
|
|
45
|
+
out.append(row)
|
|
46
|
+
return out
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class MapGenerator:
|
|
50
|
+
"""Generates a ranked, byte-bounded CODE_INTEL.md from the graph index."""
|
|
51
|
+
|
|
52
|
+
def __init__(self, provider: Optional[GraphProvider] = None) -> None:
|
|
53
|
+
self._provider = provider
|
|
54
|
+
|
|
55
|
+
def generate(self, project_root: str, budget_bytes: int = 32768) -> str:
|
|
56
|
+
provider = self._provider
|
|
57
|
+
if not provider or not getattr(provider, "available", False):
|
|
58
|
+
return _minimal_map(project_root, note="graph engine not available — install codebase-memory-mcp and run `codeintel index`")
|
|
59
|
+
|
|
60
|
+
# Top-level guard: the map is best-effort orientation — a malformed backend
|
|
61
|
+
# response must degrade to a minimal map, never crash the caller (never-raise).
|
|
62
|
+
try:
|
|
63
|
+
project = provider._resolve_project(project_root)
|
|
64
|
+
if project is None:
|
|
65
|
+
return _minimal_map(project_root, note="project not yet indexed — run `codeintel index` first")
|
|
66
|
+
|
|
67
|
+
# Query 1: architecture overview
|
|
68
|
+
arch_result = provider.build_result("overview", "", [], 5000, project_root)
|
|
69
|
+
arch_text: Optional[str] = None
|
|
70
|
+
if arch_result and arch_result.get("ok") and arch_result.get("result"):
|
|
71
|
+
arch_text = str(arch_result["result"])
|
|
72
|
+
|
|
73
|
+
# Query 2: ranked symbols by fan-in
|
|
74
|
+
ranked_rows = _query_ranked_symbols(provider, project)
|
|
75
|
+
|
|
76
|
+
# Query 3: entry points
|
|
77
|
+
entry_rows = _query_entry_points(provider, project)
|
|
78
|
+
|
|
79
|
+
content = _render(project_root, arch_text, ranked_rows, entry_rows)
|
|
80
|
+
content = _enforce_budget(content, budget_bytes, project_root, arch_text, ranked_rows, entry_rows)
|
|
81
|
+
return content
|
|
82
|
+
except Exception as exc:
|
|
83
|
+
return _minimal_map(project_root, note=f"map generation failed: {exc}")
|
|
84
|
+
|
|
85
|
+
def write(self, project_root: str, content: str) -> str:
|
|
86
|
+
path = os.path.join(project_root, "CODE_INTEL.md")
|
|
87
|
+
try:
|
|
88
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
89
|
+
f.write(content)
|
|
90
|
+
except Exception as exc:
|
|
91
|
+
import logging
|
|
92
|
+
logging.getLogger(__name__).warning("CODE_INTEL.md write failed: %s", exc)
|
|
93
|
+
return path
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _query_ranked_symbols(provider: GraphProvider, project: str) -> list[dict]:
|
|
97
|
+
raw = provider._run("query_graph", {"project": project, "query": _FAN_IN_CYPHER}, 8000)
|
|
98
|
+
rows = []
|
|
99
|
+
for row in _rows_from(raw):
|
|
100
|
+
name = row.get("fn.name") or row.get("name") or "?"
|
|
101
|
+
path = str(row.get("fn.file_path") or row.get("file_path") or "")
|
|
102
|
+
deg = row.get("in_degree", 0)
|
|
103
|
+
if path in _RANK_SKIP_PATHS: # drop builtins / project node noise
|
|
104
|
+
continue
|
|
105
|
+
rows.append({"name": name, "file_path": path, "in_degree": deg})
|
|
106
|
+
if len(rows) >= 30:
|
|
107
|
+
break
|
|
108
|
+
return rows
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _query_entry_points(provider: GraphProvider, project: str) -> list[dict]:
|
|
112
|
+
raw = provider._run("query_graph", {"project": project, "query": _ENTRY_CYPHER}, 5000)
|
|
113
|
+
rows = []
|
|
114
|
+
for row in _rows_from(raw):
|
|
115
|
+
name = row.get("fn.name") or row.get("name") or "?"
|
|
116
|
+
path = str(row.get("fn.file_path") or row.get("file_path") or "")
|
|
117
|
+
rows.append({"name": name, "file_path": path})
|
|
118
|
+
return rows
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _render(
|
|
122
|
+
project_root: str,
|
|
123
|
+
arch_text: Optional[str],
|
|
124
|
+
ranked_rows: list[dict],
|
|
125
|
+
entry_rows: list[dict],
|
|
126
|
+
) -> str:
|
|
127
|
+
repo_name = os.path.basename(os.path.abspath(project_root)) if project_root else "repo"
|
|
128
|
+
parts: list[str] = [f"# CODE_INTEL.md — {repo_name}\n"]
|
|
129
|
+
parts.append(
|
|
130
|
+
"> Auto-generated by `codeintel map`. Re-run after `codeintel index` to refresh.\n"
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
if arch_text:
|
|
134
|
+
parts.append("## Architecture Overview\n")
|
|
135
|
+
parts.append(arch_text.strip())
|
|
136
|
+
parts.append("")
|
|
137
|
+
|
|
138
|
+
if ranked_rows:
|
|
139
|
+
parts.append("## Ranked Symbols (by caller count)\n")
|
|
140
|
+
parts.append("| Symbol | File | Callers |")
|
|
141
|
+
parts.append("|--------|------|---------|")
|
|
142
|
+
for row in ranked_rows:
|
|
143
|
+
parts.append(f"| `{row['name']}` | {row['file_path']} | {row['in_degree']} |")
|
|
144
|
+
parts.append("")
|
|
145
|
+
else:
|
|
146
|
+
parts.append("## Ranked Symbols\n")
|
|
147
|
+
parts.append("_(no symbols found — run `codeintel index` first)_\n")
|
|
148
|
+
|
|
149
|
+
if entry_rows:
|
|
150
|
+
parts.append("## Entry Points\n")
|
|
151
|
+
for row in entry_rows:
|
|
152
|
+
parts.append(f"- `{row['name']}` ({row['file_path']})")
|
|
153
|
+
parts.append("")
|
|
154
|
+
|
|
155
|
+
return "\n".join(parts)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _enforce_budget(
|
|
159
|
+
content: str,
|
|
160
|
+
budget_bytes: int,
|
|
161
|
+
project_root: str,
|
|
162
|
+
arch_text: Optional[str],
|
|
163
|
+
ranked_rows: list[dict],
|
|
164
|
+
entry_rows: list[dict],
|
|
165
|
+
) -> str:
|
|
166
|
+
if budget_bytes <= 0:
|
|
167
|
+
budget_bytes = 32768
|
|
168
|
+
|
|
169
|
+
if len(content.encode("utf-8")) <= budget_bytes:
|
|
170
|
+
return content
|
|
171
|
+
|
|
172
|
+
# Binary-search-style: drop rows from ranked table until we fit, then append notice
|
|
173
|
+
rows = list(ranked_rows)
|
|
174
|
+
notice = f"\n> Content truncated to fit {budget_bytes} byte budget.\n"
|
|
175
|
+
while rows:
|
|
176
|
+
candidate = _render(project_root, arch_text, rows, entry_rows) + notice
|
|
177
|
+
if len(candidate.encode("utf-8")) <= budget_bytes:
|
|
178
|
+
return candidate
|
|
179
|
+
rows = rows[:-1]
|
|
180
|
+
|
|
181
|
+
# Even with no ranked rows the header+notice must still be returned
|
|
182
|
+
candidate = _render(project_root, arch_text, [], entry_rows) + notice
|
|
183
|
+
return candidate
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _minimal_map(project_root: str, note: str) -> str:
|
|
187
|
+
repo_name = os.path.basename(os.path.abspath(project_root)) if project_root else "repo"
|
|
188
|
+
return (
|
|
189
|
+
f"# CODE_INTEL.md — {repo_name}\n\n"
|
|
190
|
+
f"> Auto-generated by `codeintel map`.\n\n"
|
|
191
|
+
f"**Note**: {note}\n"
|
|
192
|
+
)
|