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.

codeintel/cache.py ADDED
@@ -0,0 +1,66 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import os
5
+ import threading
6
+ from typing import Optional
7
+
8
+ from codeintel.provider import Result
9
+
10
+
11
+ def _compute_hash(target: str, project_root: str) -> str:
12
+ try:
13
+ root = os.path.realpath(project_root) if project_root else ""
14
+ path = os.path.realpath(target)
15
+ if root and path.startswith(root + os.sep) or path == root:
16
+ if os.path.isfile(path):
17
+ with open(path, "rb") as fh:
18
+ return hashlib.sha256(fh.read()).hexdigest()
19
+ except Exception:
20
+ pass
21
+ return hashlib.sha256(target.encode()).hexdigest()
22
+
23
+
24
+ class ContentHashCache:
25
+ def __init__(self) -> None:
26
+ self._lock = threading.Lock()
27
+ # key → (content_hash, Result)
28
+ self._store: dict[tuple[str, str, str, str], tuple[str, Result]] = {}
29
+
30
+ def get(
31
+ self,
32
+ op: str,
33
+ target: str,
34
+ engine: str,
35
+ project_root: str,
36
+ freshness: int = 0,
37
+ ) -> Optional[Result]:
38
+ key = (op, target, engine, project_root)
39
+ with self._lock:
40
+ entry = self._store.get(key)
41
+ if entry is None:
42
+ return None
43
+ stored_hash, result = entry
44
+ current_hash = f"{_compute_hash(target, project_root)}:{freshness}"
45
+ if current_hash == stored_hash:
46
+ return result
47
+ return None
48
+
49
+ def put(
50
+ self,
51
+ op: str,
52
+ target: str,
53
+ engine: str,
54
+ project_root: str,
55
+ result: Optional[Result],
56
+ freshness: int = 0,
57
+ ) -> None:
58
+ if result is None or result.get("result") is None:
59
+ return
60
+ key = (op, target, engine, project_root)
61
+ # Fold the project's index generation into the stored hash so a completed reindex
62
+ # (which bumps `freshness`) forces a refresh even when the target isn't a file whose
63
+ # content hash would change — i.e. every symbol/free-text query.
64
+ content_hash = f"{_compute_hash(target, project_root)}:{freshness}"
65
+ with self._lock:
66
+ self._store[key] = (content_hash, result)
codeintel/config.py ADDED
@@ -0,0 +1,42 @@
1
+ from __future__ import annotations
2
+
3
+ import pathlib
4
+ import sys
5
+
6
+ if sys.version_info >= (3, 11):
7
+ import tomllib
8
+ else: # pragma: no cover
9
+ try:
10
+ import tomllib # type: ignore[no-redef]
11
+ except ImportError:
12
+ import tomli as tomllib # type: ignore[no-redef]
13
+
14
+ _DEFAULTS: dict = {
15
+ "backend": "auto",
16
+ "semantic": "on",
17
+ "reindex": "on-demand",
18
+ "window": 20,
19
+ "stride": 10,
20
+ "max_chunks": 500,
21
+ "cosine_floor": 0.25,
22
+ "model": "BAAI/bge-small-en-v1.5",
23
+ }
24
+
25
+
26
+ def _read_toml(path: pathlib.Path) -> dict:
27
+ try:
28
+ with path.open("rb") as fh:
29
+ return tomllib.load(fh)
30
+ except (FileNotFoundError, OSError, tomllib.TOMLDecodeError):
31
+ return {}
32
+
33
+
34
+ def load_config(project_root: str | None = None) -> dict:
35
+ """Return merged config: defaults < global < project."""
36
+ root = pathlib.Path(project_root) if project_root is not None else pathlib.Path.cwd()
37
+
38
+ global_cfg = _read_toml(pathlib.Path.home() / ".codeintel" / "config.toml")
39
+ project_cfg = _read_toml(root / ".codeintel.toml")
40
+
41
+ merged = {**_DEFAULTS, **global_cfg, **project_cfg}
42
+ return merged
codeintel/doctor.py ADDED
@@ -0,0 +1,161 @@
1
+ """Preflight diagnostics — turn the tool's silent safe-null degradation into a clear signal.
2
+
3
+ `run_doctor` asks each engine three questions — installed? runnable? is THIS repo indexed? —
4
+ with a one-line remediation per gap. It is never-raise and bounded: no engine check may hang,
5
+ crash, load the embedding model, mutate state, or go through the gateway (no reindex side
6
+ effects). The same report drives the CLI `doctor` command, the `code.doctor` MCP tool, and HTTP.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ from typing import Any, Callable, Optional
12
+
13
+ _ENGINES = ("graph", "lsp", "semantic")
14
+
15
+
16
+ def _status_for(report: dict) -> str:
17
+ """Roll a probe dict up to ok / warn / fail.
18
+
19
+ fail = not installed, not runnable, or (graph/semantic) repo not indexed — all actionable.
20
+ warn = installed but readiness unknown (lsp not-yet-warmed, or a deep boot that timed out).
21
+ ok = installed, runnable, and (where applicable) this repo is indexed."""
22
+ if not report.get("installed"):
23
+ return "fail"
24
+ runnable = report.get("runnable")
25
+ if runnable is False:
26
+ return "fail"
27
+ if report.get("repo_indexed") is False:
28
+ return "fail"
29
+ if runnable is None:
30
+ return "warn"
31
+ return "ok"
32
+
33
+
34
+ def _probe_engine(
35
+ engine: str,
36
+ provider: Any,
37
+ build: Callable[[], Any],
38
+ call: Callable[[Any], dict],
39
+ ) -> dict:
40
+ """Run one engine's probe, never raising. Uses the passed-in (live) provider when given,
41
+ else builds an ephemeral one."""
42
+ try:
43
+ p = provider if provider is not None else build()
44
+ except Exception as exc:
45
+ r = {"installed": False, "runnable": False, "repo_indexed": None,
46
+ "detail": f"could not construct provider ({type(exc).__name__})", "remediation": None}
47
+ return {"engine": engine, "status": "fail", **r}
48
+ try:
49
+ r = dict(call(p) or {})
50
+ except Exception as exc:
51
+ r = {"installed": None, "runnable": False, "repo_indexed": None,
52
+ "detail": f"probe raised ({type(exc).__name__})", "remediation": None}
53
+ r["engine"] = engine
54
+ r["status"] = _status_for(r)
55
+ return r
56
+
57
+
58
+ def run_doctor(
59
+ project_root: Any,
60
+ *,
61
+ deep: bool = False,
62
+ graph: Any = None,
63
+ lsp: Any = None,
64
+ semantic: Any = None,
65
+ lsp_deep_timeout_s: float = 20.0,
66
+ ) -> dict:
67
+ """Diagnose all three engines for ``project_root``. Never raises; bounded (~3s shallow).
68
+
69
+ Pass live providers (e.g. the singleton gateway's) to reflect real warmed state; omit them
70
+ for a hermetic check that builds fresh providers."""
71
+ try:
72
+ root = os.path.abspath(str(project_root)) if project_root else os.getcwd()
73
+ except Exception:
74
+ root = str(project_root or "")
75
+
76
+ engines: dict[str, dict] = {}
77
+ try:
78
+ from codeintel.providers.graph import GraphProvider
79
+ engines["graph"] = _probe_engine(
80
+ "graph", graph, GraphProvider, lambda p: p.probe(root)
81
+ )
82
+ except Exception:
83
+ engines["graph"] = {"engine": "graph", "status": "fail", "installed": False,
84
+ "runnable": False, "repo_indexed": None,
85
+ "detail": "graph provider unavailable", "remediation": None}
86
+ try:
87
+ from codeintel.providers.lsp import LspProvider
88
+ engines["lsp"] = _probe_engine(
89
+ "lsp", lsp, LspProvider, lambda p: p.probe(root, deep=deep, timeout_s=lsp_deep_timeout_s)
90
+ )
91
+ except Exception:
92
+ engines["lsp"] = {"engine": "lsp", "status": "fail", "installed": False,
93
+ "runnable": False, "repo_indexed": None,
94
+ "detail": "lsp provider unavailable", "remediation": None}
95
+ try:
96
+ from codeintel.providers.semantic import SemanticProvider
97
+ engines["semantic"] = _probe_engine(
98
+ "semantic", semantic, SemanticProvider, lambda p: p.probe(root)
99
+ )
100
+ except Exception:
101
+ engines["semantic"] = {"engine": "semantic", "status": "fail", "installed": False,
102
+ "runnable": False, "repo_indexed": None,
103
+ "detail": "semantic provider unavailable", "remediation": None}
104
+
105
+ ready = sum(1 for e in engines.values() if e.get("status") != "fail")
106
+ return {
107
+ "ok": True,
108
+ "project_root": root,
109
+ "deep": bool(deep),
110
+ "summary": {"ready": ready, "total": len(engines),
111
+ "healthy": all(e.get("status") != "fail" for e in engines.values())},
112
+ "engines": engines,
113
+ }
114
+
115
+
116
+ def render_doctor_text(report: dict) -> str:
117
+ """Human-readable CLI rendering: a per-engine ✓/✗/▲ table + two-line `fix:` remediation.
118
+ Styled via codeintel.term (color only on a TTY; width-safe glyphs so columns stay aligned)."""
119
+ from codeintel.term import c # imported at call time to honor the CLI's term.configure()
120
+
121
+ _NAME, _INST, _RUN, _REPO = 10, 11, 10, 14
122
+ root = report.get("project_root", "")
123
+ engines = report.get("engines", {})
124
+ out = [c.header("doctor", root), ""]
125
+ out.append(" " + c.bold(
126
+ "engine".ljust(_NAME) + " " + "installed".center(_INST) + " "
127
+ + "runnable".center(_RUN) + " " + "repo-indexed".center(_REPO)
128
+ ))
129
+ out.append(" " + c.rule(_NAME) + " " + c.rule(_INST) + " " + c.rule(_RUN) + " " + c.rule(_REPO))
130
+
131
+ def _state(value, na_ok=False):
132
+ if value is True:
133
+ return "ok"
134
+ if value is False:
135
+ return "fail"
136
+ return "na" if na_ok else "warn"
137
+
138
+ notes: list[tuple] = []
139
+ for name in _ENGINES:
140
+ e = engines.get(name, {})
141
+ inst = c.status_cell(_state(e.get("installed")), _INST)
142
+ run = c.status_cell(_state(e.get("runnable")), _RUN)
143
+ repo = c.status_cell(_state(e.get("repo_indexed"), na_ok=True), _REPO)
144
+ out.append(" " + name.ljust(_NAME) + " " + inst + " " + run + " " + repo)
145
+ if e.get("status") != "ok":
146
+ notes.append((name, e.get("detail", ""), e.get("remediation")))
147
+
148
+ for name, detail, rem in notes:
149
+ out.append("")
150
+ out.append(" " + c.dim("└─") + " " + c.cyan(name) + ": " + detail)
151
+ if rem:
152
+ out.append(" " + c.bold(c.cyan("fix:")) + " " + rem)
153
+
154
+ summ = report.get("summary", {})
155
+ ready, total, healthy = summ.get("ready", "?"), summ.get("total", "?"), summ.get("healthy")
156
+ count = c.bold(f"{ready} / {total}")
157
+ count = c.red(count) if healthy is False else (c.green(count) if healthy else count)
158
+ tail = "" if report.get("deep") else c.dim(" (run with --deep to boot-check serena)")
159
+ out.append("")
160
+ out.append(f" {count} engines ready for this repo.{tail}")
161
+ return "\n".join(out)
codeintel/gateway.py ADDED
@@ -0,0 +1,228 @@
1
+ from __future__ import annotations
2
+
3
+ from concurrent.futures import ThreadPoolExecutor, as_completed
4
+ from typing import Any
5
+
6
+ from codeintel.cache import ContentHashCache
7
+ from codeintel.policy import TieringPolicy
8
+ from codeintel.provider import Result, safe_null_result
9
+ from codeintel.providers.none import NoneProvider
10
+ from codeintel.reindexer import Reindexer
11
+
12
+ _KNOWN_ENGINES: frozenset[str] = frozenset({"graph", "lsp", "semantic", "auto", "both", "all"})
13
+ _FANOUT_ENGINES: frozenset[str] = frozenset({"both", "all"})
14
+
15
+ # op → preferred single engine for auto-dispatch
16
+ _AUTO_ENGINE: dict[str, str] = {
17
+ "impact": "graph",
18
+ "callers": "graph",
19
+ "callees": "graph",
20
+ "chain": "graph",
21
+ "pattern": "graph",
22
+ "overview": "graph",
23
+ "symbol": "lsp",
24
+ "search": "semantic",
25
+ "context": "both", # fan-out; resolved in Phase 4
26
+ }
27
+
28
+
29
+ class Gateway:
30
+ def __init__(self, graph=None, lsp=None, semantic=None, policy: TieringPolicy | None = None, reindexer: Reindexer | None = None):
31
+ # Backward-compat: old tests pass a list as the first positional arg.
32
+ if isinstance(graph, list):
33
+ self._legacy_providers: list | None = graph
34
+ self.graph = None
35
+ self.lsp = None
36
+ self.semantic = None
37
+ else:
38
+ self._legacy_providers = None
39
+ self.graph = graph
40
+ self.lsp = lsp
41
+ self.semantic = semantic
42
+ self._none = NoneProvider()
43
+ self._cache = ContentHashCache()
44
+ self._policy = policy
45
+ self._reindexer = reindexer or Reindexer()
46
+
47
+ def _provider_for(self, engine_str: str):
48
+ if engine_str == "graph":
49
+ return self.graph
50
+ if engine_str == "lsp":
51
+ return self.lsp
52
+ if engine_str == "semantic":
53
+ return self.semantic
54
+ return None
55
+
56
+ def _fan_out(
57
+ self,
58
+ engines: list[str],
59
+ op_str: str,
60
+ target_str: str,
61
+ budget: Any,
62
+ project_root: Any,
63
+ ) -> dict[str, Result]:
64
+ def _call(engine_str: str) -> tuple[str, Result]:
65
+ provider = self._provider_for(engine_str)
66
+ return engine_str, self._dispatch_single(
67
+ provider, op_str, target_str, budget, project_root, engine_str
68
+ )
69
+
70
+ results: dict[str, Result] = {}
71
+ try:
72
+ with ThreadPoolExecutor(max_workers=3) as executor:
73
+ futures = {executor.submit(_call, e): e for e in engines}
74
+ for future in as_completed(futures):
75
+ try:
76
+ engine_str, result = future.result()
77
+ results[engine_str] = result
78
+ except Exception:
79
+ engine_str = futures[future]
80
+ results[engine_str] = safe_null_result(
81
+ op_str, target_str, engine=engine_str, reason="provider-error"
82
+ )
83
+ except Exception:
84
+ for e in engines:
85
+ if e not in results:
86
+ results[e] = safe_null_result(op_str, target_str, engine=e, reason="provider-error")
87
+ return results
88
+
89
+ def _merge(
90
+ self,
91
+ results: dict[str, Result],
92
+ op_str: str,
93
+ target_str: str,
94
+ engine_str: str = "merged",
95
+ ) -> Result:
96
+ parts: list[str] = []
97
+ for eng, r in results.items():
98
+ if r.get("result") is not None:
99
+ parts.append(f"## [{eng}]\n{r['result']}")
100
+
101
+ if not parts:
102
+ return safe_null_result(op_str, target_str, engine=engine_str, reason="no-result")
103
+
104
+ return {
105
+ "ok": True,
106
+ "op": op_str,
107
+ "target": target_str,
108
+ "result": "\n\n".join(parts),
109
+ "engine": engine_str,
110
+ "cached": False,
111
+ }
112
+
113
+ def _dispatch_single(
114
+ self,
115
+ provider,
116
+ op_str: str,
117
+ target_str: str,
118
+ budget,
119
+ project_root,
120
+ engine_str: str,
121
+ ) -> Result:
122
+ if provider is None:
123
+ return safe_null_result(op_str, target_str, engine=engine_str, reason="engine-unavailable")
124
+ if not getattr(provider, "available", True):
125
+ return safe_null_result(op_str, target_str, engine=engine_str, reason="engine-unavailable")
126
+ try:
127
+ r = provider.build_result(op_str, target_str, [], budget or 0, project_root or "")
128
+ if r is not None:
129
+ return r
130
+ return safe_null_result(op_str, target_str, engine=engine_str, reason="no-result")
131
+ except Exception:
132
+ return safe_null_result(op_str, target_str, engine=engine_str, reason="provider-error")
133
+
134
+ def query(
135
+ self,
136
+ op=None,
137
+ target=None,
138
+ engine=None,
139
+ role: str = "",
140
+ budget=None,
141
+ project_root=None,
142
+ ) -> Result:
143
+ try:
144
+ try:
145
+ self._reindexer.maybe_reindex(str(project_root or ""))
146
+ except Exception:
147
+ pass
148
+
149
+ op_str = str(op or "")
150
+ target_str = str(target or "")
151
+ engine_str = str(engine or "").strip() or "auto"
152
+ was_auto = engine_str == "auto"
153
+
154
+ # Legacy list-based path (backward compat with pre-Phase-2 tests)
155
+ if self._legacy_providers is not None:
156
+ for p in self._legacy_providers:
157
+ try:
158
+ r = p.build_result(op_str, target_str, [], budget or 0, project_root or "")
159
+ if r is not None:
160
+ return r
161
+ except Exception:
162
+ continue
163
+ reason = "engine-unavailable" if engine is not None else "no-result"
164
+ return safe_null_result(op_str, target_str, reason=reason)
165
+
166
+ # Policy check — before cache lookup
167
+ if self._policy is not None and not self._policy.is_allowed(role, op_str):
168
+ return safe_null_result(op_str, target_str, reason="op-not-allowed-for-role")
169
+
170
+ # Unknown engine — reject immediately
171
+ if engine_str not in _KNOWN_ENGINES:
172
+ return safe_null_result(op_str, target_str, reason="unknown-engine")
173
+
174
+ # Auto: resolve by op
175
+ if engine_str == "auto":
176
+ engine_str = _AUTO_ENGINE.get(op_str, "graph")
177
+
178
+ root_str = project_root or ""
179
+
180
+ # Freshness token — bumps when a background reindex completes, so a cached
181
+ # structural answer (a symbol/free-text target, whose content hash never
182
+ # changes) is invalidated once the index actually moves. 0 when unavailable.
183
+ try:
184
+ freshness = self._reindexer.generation(root_str)
185
+ except Exception:
186
+ freshness = 0
187
+
188
+ # Fan-out: dispatch to multiple engines concurrently and merge
189
+ if engine_str in _FANOUT_ENGINES:
190
+ cached_result = self._cache.get(op_str, target_str, engine_str, root_str, freshness)
191
+ if cached_result is not None:
192
+ return {**cached_result, "cached": True}
193
+ if engine_str == "both":
194
+ engines = ["graph", "lsp"]
195
+ else: # "all"
196
+ engines = ["graph", "lsp", "semantic"]
197
+ fan_results = self._fan_out(engines, op_str, target_str, budget, project_root)
198
+ result = self._merge(fan_results, op_str, target_str, engine_str)
199
+ self._cache.put(op_str, target_str, engine_str, root_str, result, freshness)
200
+ return result
201
+
202
+ # Single-engine dispatch
203
+ cached_result = self._cache.get(op_str, target_str, engine_str, root_str, freshness)
204
+ if cached_result is not None:
205
+ return {**cached_result, "cached": True}
206
+ provider = self._provider_for(engine_str)
207
+ result = self._dispatch_single(provider, op_str, target_str, budget, project_root, engine_str)
208
+
209
+ # overview auto-fallback (F4 Story 2): when auto-routed to graph but graph is
210
+ # unavailable, try lsp — a file/symbol overview is something lsp can also serve.
211
+ if (
212
+ was_auto
213
+ and op_str == "overview"
214
+ and engine_str == "graph"
215
+ and result.get("result") is None
216
+ and result.get("reason") == "engine-unavailable"
217
+ ):
218
+ lsp_result = self._dispatch_single(
219
+ self.lsp, op_str, target_str, budget, project_root, "lsp"
220
+ )
221
+ if lsp_result.get("result") is not None:
222
+ result = lsp_result
223
+
224
+ self._cache.put(op_str, target_str, engine_str, root_str, result, freshness)
225
+ return result
226
+
227
+ except Exception:
228
+ return safe_null_result(op or "", target or "", reason="gateway-error")
@@ -0,0 +1,93 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import sys
5
+ from http.server import BaseHTTPRequestHandler, HTTPServer
6
+
7
+ from codeintel.server import code_doctor_handler, code_query_handler, code_status_handler
8
+
9
+ _MAX_BODY_BYTES = 1_048_576 # 1 MiB
10
+
11
+
12
+ class _Handler(BaseHTTPRequestHandler):
13
+ def log_message(self, format: str, *args: object) -> None:
14
+ pass # suppress default stderr noise
15
+
16
+ def _send_json(self, status: int, data: dict) -> None:
17
+ body = json.dumps(data).encode()
18
+ self.send_response(status)
19
+ self.send_header("Content-Type", "application/json")
20
+ self.send_header("Content-Length", str(len(body)))
21
+ self.end_headers()
22
+ self.wfile.write(body)
23
+
24
+ def do_POST(self) -> None:
25
+ if self.path not in ("/code/query", "/code/doctor"):
26
+ self._send_json(404, {"error": "not-found"})
27
+ return
28
+ try:
29
+ content_length = int(self.headers.get("Content-Length", 0))
30
+ except (TypeError, ValueError):
31
+ self._send_json(400, {"error": "bad-request"})
32
+ return
33
+ if content_length > _MAX_BODY_BYTES:
34
+ self.close_connection = True # do not read the oversized body
35
+ self._send_json(413, {"error": "payload-too-large", "max_bytes": _MAX_BODY_BYTES})
36
+ return
37
+ raw = self.rfile.read(content_length) if content_length > 0 else b""
38
+ try:
39
+ parsed = json.loads(raw)
40
+ except (json.JSONDecodeError, ValueError):
41
+ self._send_json(400, {"error": "bad-request"})
42
+ return
43
+ if not isinstance(parsed, dict):
44
+ self._send_json(400, {"error": "bad-request"})
45
+ return
46
+ if self.path == "/code/doctor":
47
+ result = code_doctor_handler(parsed)
48
+ else:
49
+ result = code_query_handler(parsed)
50
+ self._send_json(200, result)
51
+
52
+ def do_GET(self) -> None:
53
+ if self.path != "/code/status":
54
+ self._send_json(404, {"error": "not-found"})
55
+ return
56
+ result = code_status_handler({})
57
+ self._send_json(200, result)
58
+
59
+
60
+ class CodeIntelHTTPServer(HTTPServer):
61
+ pass
62
+
63
+
64
+ _LOOPBACK_NAMES = {"localhost"}
65
+
66
+
67
+ def _is_loopback(host: str) -> bool:
68
+ # Treat as loopback ONLY: the literal name "localhost", or an IP literal in a loopback range
69
+ # (127.0.0.0/8, ::1). A string-prefix test like host.startswith("127.") is unsafe — it would
70
+ # accept an attacker-controlled HOSTNAME such as "127.0.0.1.evil.example", bypassing the guard.
71
+ import ipaddress
72
+
73
+ h = (host or "").strip().lower()
74
+ if h in _LOOPBACK_NAMES:
75
+ return True
76
+ try:
77
+ return ipaddress.ip_address(h).is_loopback
78
+ except ValueError:
79
+ return False # a non-IP hostname is never treated as loopback
80
+
81
+
82
+ def run(host: str = "127.0.0.1", port: int = 8766, *, allow_remote: bool = False) -> None:
83
+ if not _is_loopback(host) and not allow_remote:
84
+ print(f"refusing to bind non-loopback host {host!r} without --allow-remote — this would "
85
+ f"expose an UNAUTHENTICATED code-intel endpoint (your indexed repo) to the network",
86
+ file=sys.stderr)
87
+ raise SystemExit(2)
88
+ if not _is_loopback(host):
89
+ print(f"WARNING: serving codeintel on {host}:{port} with NO authentication — anyone who can "
90
+ f"reach this port can read your indexed repo", file=sys.stderr)
91
+ server = CodeIntelHTTPServer((host, port), _Handler)
92
+ print(f"Listening on http://{host}:{port}")
93
+ server.serve_forever()