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/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")
|
codeintel/http_server.py
ADDED
|
@@ -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()
|