codecortex 0.8.0__tar.gz → 0.8.2__tar.gz
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.8.0/src/codecortex.egg-info → codecortex-0.8.2}/PKG-INFO +1 -1
- {codecortex-0.8.0 → codecortex-0.8.2/src/codecortex.egg-info}/PKG-INFO +1 -1
- {codecortex-0.8.0 → codecortex-0.8.2}/src/codecortex.egg-info/SOURCES.txt +1 -0
- codecortex-0.8.2/src/codeintel/__init__.py +1 -0
- {codecortex-0.8.0 → codecortex-0.8.2}/src/codeintel/__main__.py +6 -2
- codecortex-0.8.2/src/codeintel/installer.py +120 -0
- {codecortex-0.8.0 → codecortex-0.8.2}/src/codeintel/mapper.py +46 -4
- {codecortex-0.8.0 → codecortex-0.8.2}/src/codeintel/providers/graph.py +8 -0
- {codecortex-0.8.0 → codecortex-0.8.2}/src/codeintel/server.py +5 -3
- {codecortex-0.8.0 → codecortex-0.8.2}/tests/test_graph_provider.py +13 -0
- codecortex-0.8.2/tests/test_installer.py +97 -0
- {codecortex-0.8.0 → codecortex-0.8.2}/tests/test_mapper.py +57 -3
- codecortex-0.8.0/src/codeintel/__init__.py +0 -1
- codecortex-0.8.0/src/codeintel/installer.py +0 -113
- {codecortex-0.8.0 → codecortex-0.8.2}/LICENSE +0 -0
- {codecortex-0.8.0 → codecortex-0.8.2}/README.md +0 -0
- {codecortex-0.8.0 → codecortex-0.8.2}/pyproject.toml +0 -0
- {codecortex-0.8.0 → codecortex-0.8.2}/setup.cfg +0 -0
- {codecortex-0.8.0 → codecortex-0.8.2}/src/codecortex.egg-info/dependency_links.txt +0 -0
- {codecortex-0.8.0 → codecortex-0.8.2}/src/codecortex.egg-info/entry_points.txt +0 -0
- {codecortex-0.8.0 → codecortex-0.8.2}/src/codecortex.egg-info/requires.txt +0 -0
- {codecortex-0.8.0 → codecortex-0.8.2}/src/codecortex.egg-info/top_level.txt +0 -0
- {codecortex-0.8.0 → codecortex-0.8.2}/src/codeintel/auth.py +0 -0
- {codecortex-0.8.0 → codecortex-0.8.2}/src/codeintel/cache.py +0 -0
- {codecortex-0.8.0 → codecortex-0.8.2}/src/codeintel/config.py +0 -0
- {codecortex-0.8.0 → codecortex-0.8.2}/src/codeintel/doctor.py +0 -0
- {codecortex-0.8.0 → codecortex-0.8.2}/src/codeintel/gateway.py +0 -0
- {codecortex-0.8.0 → codecortex-0.8.2}/src/codeintel/http_server.py +0 -0
- {codecortex-0.8.0 → codecortex-0.8.2}/src/codeintel/indexer.py +0 -0
- {codecortex-0.8.0 → codecortex-0.8.2}/src/codeintel/injector.py +0 -0
- {codecortex-0.8.0 → codecortex-0.8.2}/src/codeintel/logconfig.py +0 -0
- {codecortex-0.8.0 → codecortex-0.8.2}/src/codeintel/metrics.py +0 -0
- {codecortex-0.8.0 → codecortex-0.8.2}/src/codeintel/onboarding.py +0 -0
- {codecortex-0.8.0 → codecortex-0.8.2}/src/codeintel/policy.py +0 -0
- {codecortex-0.8.0 → codecortex-0.8.2}/src/codeintel/provider.py +0 -0
- {codecortex-0.8.0 → codecortex-0.8.2}/src/codeintel/providers/__init__.py +0 -0
- {codecortex-0.8.0 → codecortex-0.8.2}/src/codeintel/providers/lsp.py +0 -0
- {codecortex-0.8.0 → codecortex-0.8.2}/src/codeintel/providers/none.py +0 -0
- {codecortex-0.8.0 → codecortex-0.8.2}/src/codeintel/providers/semantic.py +0 -0
- {codecortex-0.8.0 → codecortex-0.8.2}/src/codeintel/reindexer.py +0 -0
- {codecortex-0.8.0 → codecortex-0.8.2}/src/codeintel/reset.py +0 -0
- {codecortex-0.8.0 → codecortex-0.8.2}/src/codeintel/searcher.py +0 -0
- {codecortex-0.8.0 → codecortex-0.8.2}/src/codeintel/semantic_db.py +0 -0
- {codecortex-0.8.0 → codecortex-0.8.2}/src/codeintel/term.py +0 -0
- {codecortex-0.8.0 → codecortex-0.8.2}/tests/test_cache.py +0 -0
- {codecortex-0.8.0 → codecortex-0.8.2}/tests/test_chunking.py +0 -0
- {codecortex-0.8.0 → codecortex-0.8.2}/tests/test_config.py +0 -0
- {codecortex-0.8.0 → codecortex-0.8.2}/tests/test_doctor.py +0 -0
- {codecortex-0.8.0 → codecortex-0.8.2}/tests/test_e2e.py +0 -0
- {codecortex-0.8.0 → codecortex-0.8.2}/tests/test_enterprise.py +0 -0
- {codecortex-0.8.0 → codecortex-0.8.2}/tests/test_gateway.py +0 -0
- {codecortex-0.8.0 → codecortex-0.8.2}/tests/test_graph_real.py +0 -0
- {codecortex-0.8.0 → codecortex-0.8.2}/tests/test_graph_stdin.py +0 -0
- {codecortex-0.8.0 → codecortex-0.8.2}/tests/test_hardening.py +0 -0
- {codecortex-0.8.0 → codecortex-0.8.2}/tests/test_http_auth.py +0 -0
- {codecortex-0.8.0 → codecortex-0.8.2}/tests/test_http_server.py +0 -0
- {codecortex-0.8.0 → codecortex-0.8.2}/tests/test_integration.py +0 -0
- {codecortex-0.8.0 → codecortex-0.8.2}/tests/test_lsp_provider.py +0 -0
- {codecortex-0.8.0 → codecortex-0.8.2}/tests/test_lsp_real.py +0 -0
- {codecortex-0.8.0 → codecortex-0.8.2}/tests/test_never_raise.py +0 -0
- {codecortex-0.8.0 → codecortex-0.8.2}/tests/test_onboarding.py +0 -0
- {codecortex-0.8.0 → codecortex-0.8.2}/tests/test_rbac.py +0 -0
- {codecortex-0.8.0 → codecortex-0.8.2}/tests/test_reindexer.py +0 -0
- {codecortex-0.8.0 → codecortex-0.8.2}/tests/test_rerank.py +0 -0
- {codecortex-0.8.0 → codecortex-0.8.2}/tests/test_reset.py +0 -0
- {codecortex-0.8.0 → codecortex-0.8.2}/tests/test_semantic_provider.py +0 -0
- {codecortex-0.8.0 → codecortex-0.8.2}/tests/test_term.py +0 -0
- {codecortex-0.8.0 → codecortex-0.8.2}/tests/test_treesitter.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: codecortex
|
|
3
|
-
Version: 0.8.
|
|
3
|
+
Version: 0.8.2
|
|
4
4
|
Summary: Local-first, MCP-native code-intelligence server — graph, LSP, and semantic search behind one safe code.query tool for coding agents.
|
|
5
5
|
Author: Shammai Hamilton
|
|
6
6
|
License-Expression: MIT
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: codecortex
|
|
3
|
-
Version: 0.8.
|
|
3
|
+
Version: 0.8.2
|
|
4
4
|
Summary: Local-first, MCP-native code-intelligence server — graph, LSP, and semantic search behind one safe code.query tool for coding agents.
|
|
5
5
|
Author: Shammai Hamilton
|
|
6
6
|
License-Expression: MIT
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.8.2"
|
|
@@ -166,8 +166,12 @@ def main() -> None:
|
|
|
166
166
|
gen = MapGenerator(provider)
|
|
167
167
|
try:
|
|
168
168
|
content = gen.generate(project_root, budget_bytes=args.budget)
|
|
169
|
-
path = gen.write(project_root, content)
|
|
170
|
-
|
|
169
|
+
path, wrote = gen.write(project_root, content)
|
|
170
|
+
if wrote:
|
|
171
|
+
print(f"Wrote {path} ({len(content.encode())} bytes)")
|
|
172
|
+
else:
|
|
173
|
+
print(f"Kept existing {path} — new map was a stub (graph empty/unindexed; "
|
|
174
|
+
f"run `codeintel index` first)")
|
|
171
175
|
if args.inject:
|
|
172
176
|
inj_path, inj_action = Injector().inject(project_root)
|
|
173
177
|
if inj_path:
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import pathlib
|
|
6
|
+
|
|
7
|
+
_AGENTS = ["claude", "codex", "gemini", "zed"]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _atomic_write_text(path: pathlib.Path, text: str) -> None:
|
|
11
|
+
"""Write via a sibling temp file + os.replace so an interrupted write can never truncate
|
|
12
|
+
the user's existing agent config (which holds unrelated settings) to a partial/empty file.
|
|
13
|
+
The temp lives in the same directory as ``path`` so the replace stays on one filesystem."""
|
|
14
|
+
tmp = path.with_name(path.name + ".codeintel.tmp")
|
|
15
|
+
tmp.write_text(text, encoding="utf-8")
|
|
16
|
+
os.replace(tmp, path)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# Per-agent registration recipe. Most agents take a Claude-style JSON block; Codex is different —
|
|
20
|
+
# its CLI reads MCP servers from ``~/.codex/config.toml`` as ``[mcp_servers.<name>]`` TOML tables,
|
|
21
|
+
# NOT a JSON ``mcpServers`` map in config.json (writing the latter registers nothing).
|
|
22
|
+
_CONFIG: dict[str, dict] = {
|
|
23
|
+
"claude": {
|
|
24
|
+
"format": "json",
|
|
25
|
+
"path": "~/.claude/settings.json",
|
|
26
|
+
"key": ["mcpServers", "codeintel"],
|
|
27
|
+
"value": {"command": "codeintel", "args": ["serve"]},
|
|
28
|
+
},
|
|
29
|
+
"codex": {
|
|
30
|
+
"format": "toml-mcp",
|
|
31
|
+
"path": "~/.codex/config.toml",
|
|
32
|
+
"table": "[mcp_servers.codeintel]",
|
|
33
|
+
"block": '[mcp_servers.codeintel]\ncommand = "codeintel"\nargs = ["serve"]\n',
|
|
34
|
+
},
|
|
35
|
+
"gemini": {
|
|
36
|
+
"format": "json",
|
|
37
|
+
"path": "~/.gemini/settings.json",
|
|
38
|
+
"key": ["mcpServers", "codeintel"],
|
|
39
|
+
"value": {"command": "codeintel", "args": ["serve"]},
|
|
40
|
+
},
|
|
41
|
+
"zed": {
|
|
42
|
+
"format": "json",
|
|
43
|
+
"path": "~/.config/zed/settings.json",
|
|
44
|
+
"key": ["context_servers", "codeintel"],
|
|
45
|
+
"value": {"command": {"path": "codeintel", "args": ["serve"]}},
|
|
46
|
+
},
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _get_nested(data: dict, keys: list[str]):
|
|
51
|
+
node = data
|
|
52
|
+
for k in keys:
|
|
53
|
+
if not isinstance(node, dict) or k not in node:
|
|
54
|
+
return None
|
|
55
|
+
node = node[k]
|
|
56
|
+
return node
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _set_nested(data: dict, keys: list[str], value) -> None:
|
|
60
|
+
node = data
|
|
61
|
+
for k in keys[:-1]:
|
|
62
|
+
if k not in node or not isinstance(node[k], dict):
|
|
63
|
+
node[k] = {}
|
|
64
|
+
node = node[k]
|
|
65
|
+
node[keys[-1]] = value
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class Installer:
|
|
69
|
+
def register(self, agent: str) -> dict:
|
|
70
|
+
spec = _CONFIG.get(agent)
|
|
71
|
+
if spec is None:
|
|
72
|
+
return self._result(agent, "", False, "failed", f"unknown agent '{agent}'")
|
|
73
|
+
config_path = pathlib.Path(spec["path"]).expanduser()
|
|
74
|
+
try:
|
|
75
|
+
if spec.get("format") == "toml-mcp":
|
|
76
|
+
return self._register_toml(agent, config_path, spec)
|
|
77
|
+
return self._register_json(agent, config_path, spec)
|
|
78
|
+
except Exception as exc:
|
|
79
|
+
return self._result(agent, str(config_path), False, "failed", str(exc))
|
|
80
|
+
|
|
81
|
+
def _register_json(self, agent: str, config_path: pathlib.Path, spec: dict) -> dict:
|
|
82
|
+
if config_path.exists():
|
|
83
|
+
data = json.loads(config_path.read_text(encoding="utf-8"))
|
|
84
|
+
if not isinstance(data, dict):
|
|
85
|
+
data = {}
|
|
86
|
+
else:
|
|
87
|
+
data = {}
|
|
88
|
+
|
|
89
|
+
if _get_nested(data, spec["key"]) == spec["value"]:
|
|
90
|
+
return self._result(agent, str(config_path), True, "already")
|
|
91
|
+
|
|
92
|
+
config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
93
|
+
_set_nested(data, spec["key"], spec["value"])
|
|
94
|
+
_atomic_write_text(config_path, json.dumps(data, indent=2))
|
|
95
|
+
return self._result(agent, str(config_path), True, "registered")
|
|
96
|
+
|
|
97
|
+
def _register_toml(self, agent: str, config_path: pathlib.Path, spec: dict) -> dict:
|
|
98
|
+
"""Codex: append an ``[mcp_servers.codeintel]`` table to config.toml, preserving everything
|
|
99
|
+
already there (other servers, project trust levels, hooks). Text-based on purpose — it must
|
|
100
|
+
not reformat or risk corrupting a config the user hand-edits, and it stays idempotent by
|
|
101
|
+
checking for the table header. If a codeintel entry already exists it is left untouched."""
|
|
102
|
+
existing = config_path.read_text(encoding="utf-8") if config_path.exists() else ""
|
|
103
|
+
if spec["table"] in existing:
|
|
104
|
+
return self._result(agent, str(config_path), True, "already")
|
|
105
|
+
|
|
106
|
+
config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
107
|
+
if existing == "":
|
|
108
|
+
new_text = spec["block"]
|
|
109
|
+
else:
|
|
110
|
+
prefix = existing if existing.endswith("\n") else existing + "\n"
|
|
111
|
+
new_text = prefix + "\n" + spec["block"] # blank line before the new table
|
|
112
|
+
_atomic_write_text(config_path, new_text)
|
|
113
|
+
return self._result(agent, str(config_path), True, "registered")
|
|
114
|
+
|
|
115
|
+
@staticmethod
|
|
116
|
+
def _result(agent: str, path: str, ok: bool, action: str, reason: str = "") -> dict:
|
|
117
|
+
return {"agent": agent, "path": path, "ok": ok, "action": action, "reason": reason}
|
|
118
|
+
|
|
119
|
+
def register_all(self) -> list[dict]:
|
|
120
|
+
return [self.register(agent) for agent in _AGENTS]
|
|
@@ -1,11 +1,30 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import logging
|
|
3
4
|
import os
|
|
4
5
|
from typing import Optional, TYPE_CHECKING
|
|
5
6
|
|
|
6
7
|
if TYPE_CHECKING:
|
|
7
8
|
from codeintel.providers.graph import GraphProvider
|
|
8
9
|
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
# A map with any of these sections carries real, indexed orientation content. Anything without
|
|
13
|
+
# them is a degraded stub: `_minimal_map` (graph unavailable / repo unindexed / generation failed),
|
|
14
|
+
# or a render over an empty graph. Note the ranked marker includes "(by caller count)" — the
|
|
15
|
+
# empty-graph render emits a differently-worded "## Ranked Symbols" heading that is NOT this.
|
|
16
|
+
_POPULATED_MARKERS = (
|
|
17
|
+
"## Architecture Overview",
|
|
18
|
+
"## Ranked Symbols (by caller count)",
|
|
19
|
+
"## Entry Points",
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _is_populated_map(text: str) -> bool:
|
|
24
|
+
"""True when the map has real content (architecture overview, a ranked-symbol table, or entry
|
|
25
|
+
points) rather than a degraded stub — used to avoid overwriting a good CODE_INTEL.md."""
|
|
26
|
+
return any(marker in text for marker in _POPULATED_MARKERS)
|
|
27
|
+
|
|
9
28
|
# Real codebase-memory-mcp contract (verified live): query_graph returns {columns, rows}
|
|
10
29
|
# value-arrays, module-level calls are USAGE (not only CALLS), and nodes carry `is_entry_point`
|
|
11
30
|
# (there is no `fn.type` property — that filter matched nothing, so the old queries were dead).
|
|
@@ -82,15 +101,38 @@ class MapGenerator:
|
|
|
82
101
|
except Exception as exc:
|
|
83
102
|
return _minimal_map(project_root, note=f"map generation failed: {exc}")
|
|
84
103
|
|
|
85
|
-
def write(self, project_root: str, content: str) -> str:
|
|
104
|
+
def write(self, project_root: str, content: str) -> tuple[str, bool]:
|
|
105
|
+
"""Write CODE_INTEL.md; return ``(path, wrote)``. ``wrote`` is False when an existing
|
|
106
|
+
populated map was preserved rather than clobbered by a stub (or the write failed), so
|
|
107
|
+
callers can report the outcome honestly.
|
|
108
|
+
|
|
109
|
+
Don't clobber a good map with a degraded stub: `generate` returns a stub when the graph
|
|
110
|
+
backend is unavailable or hasn't indexed this repo yet — a common transient during
|
|
111
|
+
`codeintel index`'s best-effort refresh. Overwriting a previously-populated CODE_INTEL.md
|
|
112
|
+
with that stub silently destroys real orientation content, so when the new content is a
|
|
113
|
+
stub and an existing map is populated, preserve the existing file."""
|
|
86
114
|
path = os.path.join(project_root, "CODE_INTEL.md")
|
|
115
|
+
if not _is_populated_map(content):
|
|
116
|
+
try:
|
|
117
|
+
if os.path.isfile(path):
|
|
118
|
+
with open(path, encoding="utf-8") as f:
|
|
119
|
+
existing = f.read()
|
|
120
|
+
if _is_populated_map(existing):
|
|
121
|
+
logger.warning(
|
|
122
|
+
"CODE_INTEL.md: new map is a stub (graph empty/unavailable) — keeping "
|
|
123
|
+
"the existing populated map at %s (run `codeintel index` to refresh)",
|
|
124
|
+
path,
|
|
125
|
+
)
|
|
126
|
+
return path, False
|
|
127
|
+
except Exception:
|
|
128
|
+
pass # can't read the existing file — fall through and write (best-effort)
|
|
87
129
|
try:
|
|
88
130
|
with open(path, "w", encoding="utf-8") as f:
|
|
89
131
|
f.write(content)
|
|
132
|
+
return path, True
|
|
90
133
|
except Exception as exc:
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
return path
|
|
134
|
+
logger.warning("CODE_INTEL.md write failed: %s", exc)
|
|
135
|
+
return path, False
|
|
94
136
|
|
|
95
137
|
|
|
96
138
|
def _query_ranked_symbols(provider: GraphProvider, project: str) -> list[dict]:
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
|
+
import os
|
|
4
5
|
import shutil
|
|
5
6
|
import subprocess
|
|
6
7
|
import threading
|
|
@@ -107,6 +108,13 @@ class GraphProvider:
|
|
|
107
108
|
older/mocked shape — accept both. Prefer an exact ``root_path`` match; otherwise the
|
|
108
109
|
LONGEST prefix match (so ``.../project/codeintel`` resolves to codeintel, not its
|
|
109
110
|
parent ``.../project``). Pure + static so ``_resolve_project`` and ``probe`` share it."""
|
|
111
|
+
# Normalize the input to an absolute realpath: the backend stores absolute root_paths, so a
|
|
112
|
+
# relative ``project_root`` (e.g. `codeintel map .` passing ".") would otherwise never match
|
|
113
|
+
# — the bug where the map/query silently reported "not indexed" from inside the repo.
|
|
114
|
+
try:
|
|
115
|
+
project_root = os.path.realpath(project_root)
|
|
116
|
+
except Exception:
|
|
117
|
+
pass
|
|
110
118
|
entries = raw.get("projects", []) if isinstance(raw, dict) else raw
|
|
111
119
|
if not isinstance(entries, list):
|
|
112
120
|
return None
|
|
@@ -195,15 +195,17 @@ def code_map_handler(args: dict) -> dict:
|
|
|
195
195
|
|
|
196
196
|
gen = MapGenerator(provider)
|
|
197
197
|
content = gen.generate(project_root, budget_bytes=budget)
|
|
198
|
-
path = gen.write(project_root, content)
|
|
199
|
-
|
|
198
|
+
path, wrote = gen.write(project_root, content)
|
|
199
|
+
# size_bytes = bytes actually written; 0 when an existing populated map was preserved
|
|
200
|
+
# (the new content was a stub) — `wrote` disambiguates for the caller.
|
|
201
|
+
size = len(content.encode("utf-8")) if wrote else 0
|
|
200
202
|
|
|
201
203
|
inject_result = None
|
|
202
204
|
if inject:
|
|
203
205
|
inj_path, inj_action = Injector().inject(project_root)
|
|
204
206
|
inject_result = {"path": inj_path, "action": inj_action}
|
|
205
207
|
|
|
206
|
-
return {"ok": True, "path": path, "size_bytes": size, "inject": inject_result}
|
|
208
|
+
return {"ok": True, "path": path, "size_bytes": size, "wrote": wrote, "inject": inject_result}
|
|
207
209
|
except Exception:
|
|
208
210
|
return {"ok": True, "path": None, "size_bytes": 0, "note": "map-error"}
|
|
209
211
|
|
|
@@ -1,12 +1,25 @@
|
|
|
1
1
|
"""GraphProvider tests: never-raise invariant and key behavioral guarantees."""
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
|
|
4
|
+
import os
|
|
4
5
|
import subprocess
|
|
5
6
|
|
|
6
7
|
from codeintel.providers.graph import GraphProvider
|
|
7
8
|
from codeintel.server import code_status_handler
|
|
8
9
|
|
|
9
10
|
|
|
11
|
+
def test_match_project_resolves_a_relative_path(tmp_path, monkeypatch):
|
|
12
|
+
# The backend stores absolute root_paths, so a relative project_root (e.g. `codeintel map .`)
|
|
13
|
+
# must be normalized before matching — otherwise resolution fails from inside the repo and the
|
|
14
|
+
# map/graph query silently reports "not indexed" (the map-stub bug).
|
|
15
|
+
real = os.path.realpath(str(tmp_path))
|
|
16
|
+
raw = {"projects": [{"name": "myrepo", "root_path": real}]}
|
|
17
|
+
monkeypatch.chdir(tmp_path)
|
|
18
|
+
assert GraphProvider._match_project(raw, ".") == "myrepo" # relative resolves now
|
|
19
|
+
assert GraphProvider._match_project(raw, real) == "myrepo" # absolute still works
|
|
20
|
+
assert GraphProvider._match_project(raw, os.path.join(real, "src")) == "myrepo" # subdir prefix
|
|
21
|
+
|
|
22
|
+
|
|
10
23
|
# ---------------------------------------------------------------------------
|
|
11
24
|
# Group 1 — Never-raise: None args
|
|
12
25
|
# ---------------------------------------------------------------------------
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Installer / agent-registration tests (added 0.8.2).
|
|
2
|
+
|
|
3
|
+
The installer had no tests, which is why `--agent codex` shipped writing a Claude-style JSON
|
|
4
|
+
`mcpServers` block to `~/.codex/config.json` — but Codex CLI reads MCP servers from
|
|
5
|
+
`~/.codex/config.toml` as `[mcp_servers.<name>]` TOML, so nothing was actually registered.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from codeintel.installer import Installer
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _codex_toml(home: Path) -> Path:
|
|
16
|
+
return home / ".codex" / "config.toml"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# --------------------------------------------------------------------------- codex (TOML)
|
|
20
|
+
|
|
21
|
+
def test_codex_registers_as_toml_in_config_toml(tmp_path, monkeypatch):
|
|
22
|
+
monkeypatch.setenv("HOME", str(tmp_path))
|
|
23
|
+
cfg = _codex_toml(tmp_path)
|
|
24
|
+
cfg.parent.mkdir(parents=True)
|
|
25
|
+
cfg.write_text('[mcp_servers.serena]\ncommand = "serena"\n') # pre-existing MCP server
|
|
26
|
+
|
|
27
|
+
res = Installer().register("codex")
|
|
28
|
+
|
|
29
|
+
assert res["ok"] and res["action"] == "registered"
|
|
30
|
+
text = cfg.read_text()
|
|
31
|
+
assert "[mcp_servers.codeintel]" in text # written to config.toml as TOML
|
|
32
|
+
assert 'command = "codeintel"' in text and 'args = ["serve"]' in text
|
|
33
|
+
assert "[mcp_servers.serena]" in text # existing content preserved
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_codex_does_not_write_the_wrong_json_file(tmp_path, monkeypatch):
|
|
37
|
+
# regression: the old installer wrote ~/.codex/config.json (mcpServers), which Codex ignores
|
|
38
|
+
monkeypatch.setenv("HOME", str(tmp_path))
|
|
39
|
+
Installer().register("codex")
|
|
40
|
+
assert _codex_toml(tmp_path).exists()
|
|
41
|
+
assert not (tmp_path / ".codex" / "config.json").exists()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_codex_creates_config_when_missing(tmp_path, monkeypatch):
|
|
45
|
+
monkeypatch.setenv("HOME", str(tmp_path))
|
|
46
|
+
res = Installer().register("codex")
|
|
47
|
+
assert res["ok"] and _codex_toml(tmp_path).read_text().startswith("[mcp_servers.codeintel]")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_codex_is_idempotent(tmp_path, monkeypatch):
|
|
51
|
+
monkeypatch.setenv("HOME", str(tmp_path))
|
|
52
|
+
assert Installer().register("codex")["action"] == "registered"
|
|
53
|
+
assert Installer().register("codex")["action"] == "already"
|
|
54
|
+
assert _codex_toml(tmp_path).read_text().count("[mcp_servers.codeintel]") == 1 # not duplicated
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# --------------------------------------------------------------------------- json agents
|
|
58
|
+
|
|
59
|
+
def test_claude_registers_json_mcp_servers(tmp_path, monkeypatch):
|
|
60
|
+
monkeypatch.setenv("HOME", str(tmp_path))
|
|
61
|
+
res = Installer().register("claude")
|
|
62
|
+
assert res["ok"]
|
|
63
|
+
data = json.loads((tmp_path / ".claude" / "settings.json").read_text())
|
|
64
|
+
assert data["mcpServers"]["codeintel"] == {"command": "codeintel", "args": ["serve"]}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_claude_preserves_unrelated_settings(tmp_path, monkeypatch):
|
|
68
|
+
monkeypatch.setenv("HOME", str(tmp_path))
|
|
69
|
+
s = tmp_path / ".claude" / "settings.json"
|
|
70
|
+
s.parent.mkdir(parents=True)
|
|
71
|
+
s.write_text(json.dumps({"theme": "dark", "mcpServers": {"other": {"command": "x"}}}))
|
|
72
|
+
Installer().register("claude")
|
|
73
|
+
data = json.loads(s.read_text())
|
|
74
|
+
assert data["theme"] == "dark" # unrelated key preserved
|
|
75
|
+
assert "other" in data["mcpServers"] # other server preserved
|
|
76
|
+
assert "codeintel" in data["mcpServers"] # ours added
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_zed_uses_context_servers_key(tmp_path, monkeypatch):
|
|
80
|
+
monkeypatch.setenv("HOME", str(tmp_path))
|
|
81
|
+
Installer().register("zed")
|
|
82
|
+
data = json.loads((tmp_path / ".config" / "zed" / "settings.json").read_text())
|
|
83
|
+
assert "codeintel" in data["context_servers"]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
# --------------------------------------------------------------------------- misc
|
|
87
|
+
|
|
88
|
+
def test_unknown_agent_fails_safely():
|
|
89
|
+
res = Installer().register("nonesuch")
|
|
90
|
+
assert res["ok"] is False and res["action"] == "failed" and "unknown" in res["reason"]
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def test_register_all_covers_every_agent(tmp_path, monkeypatch):
|
|
94
|
+
monkeypatch.setenv("HOME", str(tmp_path))
|
|
95
|
+
results = Installer().register_all()
|
|
96
|
+
assert {r["agent"] for r in results} == {"claude", "codex", "gemini", "zed"}
|
|
97
|
+
assert all(r["ok"] for r in results)
|
|
@@ -6,9 +6,15 @@ from unittest.mock import MagicMock
|
|
|
6
6
|
|
|
7
7
|
import pytest
|
|
8
8
|
|
|
9
|
-
from codeintel.mapper import MapGenerator
|
|
9
|
+
from codeintel.mapper import MapGenerator, _minimal_map, _is_populated_map
|
|
10
10
|
from codeintel.injector import Injector
|
|
11
11
|
|
|
12
|
+
_POPULATED = (
|
|
13
|
+
"# CODE_INTEL.md — repo\n\n"
|
|
14
|
+
"## Ranked Symbols (by caller count)\n"
|
|
15
|
+
"| Symbol | File | Callers |\n|--------|------|---------|\n| `foo` | a.py | 9 |\n"
|
|
16
|
+
)
|
|
17
|
+
|
|
12
18
|
|
|
13
19
|
def _make_provider(ranked=None, entry=None, arch=None):
|
|
14
20
|
"""Return a mocked GraphProvider with configurable return values."""
|
|
@@ -117,13 +123,61 @@ def test_generate_reads_columns_rows_shape():
|
|
|
117
123
|
def test_write_creates_file():
|
|
118
124
|
gen = MapGenerator(None)
|
|
119
125
|
with tempfile.TemporaryDirectory() as d:
|
|
120
|
-
path = gen.write(d, "# content")
|
|
126
|
+
path, wrote = gen.write(d, "# content")
|
|
121
127
|
expected = os.path.join(d, "CODE_INTEL.md")
|
|
122
|
-
assert path == expected
|
|
128
|
+
assert path == expected and wrote is True
|
|
123
129
|
assert os.path.exists(expected)
|
|
124
130
|
assert open(expected).read() == "# content"
|
|
125
131
|
|
|
126
132
|
|
|
133
|
+
def test_write_preserves_populated_map_when_new_is_a_stub():
|
|
134
|
+
# the dogfooding bug: `codeintel index`'s best-effort refresh produced a stub (graph empty/
|
|
135
|
+
# unindexed at that moment) and clobbered a rich, populated CODE_INTEL.md. It must NOT.
|
|
136
|
+
gen = MapGenerator(None)
|
|
137
|
+
with tempfile.TemporaryDirectory() as d:
|
|
138
|
+
path = os.path.join(d, "CODE_INTEL.md")
|
|
139
|
+
open(path, "w").write(_POPULATED)
|
|
140
|
+
stub = _minimal_map(d, note="project not yet indexed")
|
|
141
|
+
assert not _is_populated_map(stub) and _is_populated_map(_POPULATED) # sanity
|
|
142
|
+
p, wrote = gen.write(d, stub)
|
|
143
|
+
assert p == path and wrote is False # preserved → reported as not written
|
|
144
|
+
assert open(path).read() == _POPULATED # preserved, not stubbed
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def test_write_stub_ok_when_no_existing_map():
|
|
148
|
+
# first-ever generation of a stub is fine — there's nothing to preserve
|
|
149
|
+
gen = MapGenerator(None)
|
|
150
|
+
with tempfile.TemporaryDirectory() as d:
|
|
151
|
+
_, wrote = gen.write(d, _minimal_map(d, note="x"))
|
|
152
|
+
assert wrote is True
|
|
153
|
+
assert "Auto-generated" in open(os.path.join(d, "CODE_INTEL.md")).read()
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def test_write_populated_replaces_a_stub():
|
|
157
|
+
# a real map SHOULD replace a prior stub — the normal refresh-improves case still works
|
|
158
|
+
gen = MapGenerator(None)
|
|
159
|
+
with tempfile.TemporaryDirectory() as d:
|
|
160
|
+
path = os.path.join(d, "CODE_INTEL.md")
|
|
161
|
+
open(path, "w").write(_minimal_map(d, note="stale"))
|
|
162
|
+
_, wrote = gen.write(d, _POPULATED)
|
|
163
|
+
assert wrote is True
|
|
164
|
+
assert _is_populated_map(open(path).read())
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def test_entry_points_only_map_counts_as_populated():
|
|
168
|
+
# a sparse-but-real render (entry points, no arch/ranked table) is NOT a stub — it must refresh,
|
|
169
|
+
# not be misclassified and skipped (regression for the review's false-negative finding)
|
|
170
|
+
entry_only = ("# CODE_INTEL.md — repo\n\n## Ranked Symbols\n_(no symbols found)_\n\n"
|
|
171
|
+
"## Entry Points\n- `main` (app.py)\n")
|
|
172
|
+
assert _is_populated_map(entry_only)
|
|
173
|
+
gen = MapGenerator(None)
|
|
174
|
+
with tempfile.TemporaryDirectory() as d:
|
|
175
|
+
path = os.path.join(d, "CODE_INTEL.md")
|
|
176
|
+
open(path, "w").write(_POPULATED)
|
|
177
|
+
_, wrote = gen.write(d, entry_only) # real content replaces the old map, not skipped
|
|
178
|
+
assert wrote is True and "Entry Points" in open(path).read()
|
|
179
|
+
|
|
180
|
+
|
|
127
181
|
# ---------------------------------------------------------------------------
|
|
128
182
|
# Injector tests
|
|
129
183
|
# ---------------------------------------------------------------------------
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.8.0"
|
|
@@ -1,113 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
import json
|
|
4
|
-
import os
|
|
5
|
-
import pathlib
|
|
6
|
-
|
|
7
|
-
_AGENTS = ["claude", "codex", "gemini", "zed"]
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
def _atomic_write_text(path: pathlib.Path, text: str) -> None:
|
|
11
|
-
"""Write via a sibling temp file + os.replace so an interrupted write can never truncate
|
|
12
|
-
the user's existing agent config (which holds unrelated settings) to a partial/empty file.
|
|
13
|
-
The temp lives in the same directory as ``path`` so the replace stays on one filesystem."""
|
|
14
|
-
tmp = path.with_name(path.name + ".codeintel.tmp")
|
|
15
|
-
tmp.write_text(text, encoding="utf-8")
|
|
16
|
-
os.replace(tmp, path)
|
|
17
|
-
|
|
18
|
-
_CONFIG: dict[str, dict] = {
|
|
19
|
-
"claude": {
|
|
20
|
-
"path": "~/.claude/settings.json",
|
|
21
|
-
"key": ["mcpServers", "codeintel"],
|
|
22
|
-
"value": {"command": "codeintel", "args": ["serve"]},
|
|
23
|
-
},
|
|
24
|
-
"codex": {
|
|
25
|
-
"path": "~/.codex/config.json",
|
|
26
|
-
"key": ["mcpServers", "codeintel"],
|
|
27
|
-
"value": {"command": "codeintel", "args": ["serve"]},
|
|
28
|
-
},
|
|
29
|
-
"gemini": {
|
|
30
|
-
"path": "~/.gemini/settings.json",
|
|
31
|
-
"key": ["mcpServers", "codeintel"],
|
|
32
|
-
"value": {"command": "codeintel", "args": ["serve"]},
|
|
33
|
-
},
|
|
34
|
-
"zed": {
|
|
35
|
-
"path": "~/.config/zed/settings.json",
|
|
36
|
-
"key": ["context_servers", "codeintel"],
|
|
37
|
-
"value": {"command": {"path": "codeintel", "args": ["serve"]}},
|
|
38
|
-
},
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
def _get_nested(data: dict, keys: list[str]):
|
|
43
|
-
node = data
|
|
44
|
-
for k in keys:
|
|
45
|
-
if not isinstance(node, dict) or k not in node:
|
|
46
|
-
return None
|
|
47
|
-
node = node[k]
|
|
48
|
-
return node
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
def _set_nested(data: dict, keys: list[str], value) -> None:
|
|
52
|
-
node = data
|
|
53
|
-
for k in keys[:-1]:
|
|
54
|
-
if k not in node or not isinstance(node[k], dict):
|
|
55
|
-
node[k] = {}
|
|
56
|
-
node = node[k]
|
|
57
|
-
node[keys[-1]] = value
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
class Installer:
|
|
61
|
-
def register(self, agent: str) -> dict:
|
|
62
|
-
spec = _CONFIG.get(agent)
|
|
63
|
-
if spec is None:
|
|
64
|
-
return {
|
|
65
|
-
"agent": agent,
|
|
66
|
-
"path": "",
|
|
67
|
-
"ok": False,
|
|
68
|
-
"action": "failed",
|
|
69
|
-
"reason": f"unknown agent '{agent}'",
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
config_path = pathlib.Path(spec["path"]).expanduser()
|
|
73
|
-
try:
|
|
74
|
-
if config_path.exists():
|
|
75
|
-
data = json.loads(config_path.read_text(encoding="utf-8"))
|
|
76
|
-
if not isinstance(data, dict):
|
|
77
|
-
data = {}
|
|
78
|
-
else:
|
|
79
|
-
data = {}
|
|
80
|
-
|
|
81
|
-
current = _get_nested(data, spec["key"])
|
|
82
|
-
if current == spec["value"]:
|
|
83
|
-
return {
|
|
84
|
-
"agent": agent,
|
|
85
|
-
"path": str(config_path),
|
|
86
|
-
"ok": True,
|
|
87
|
-
"action": "already",
|
|
88
|
-
"reason": "",
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
92
|
-
_set_nested(data, spec["key"], spec["value"])
|
|
93
|
-
_atomic_write_text(config_path, json.dumps(data, indent=2))
|
|
94
|
-
|
|
95
|
-
return {
|
|
96
|
-
"agent": agent,
|
|
97
|
-
"path": str(config_path),
|
|
98
|
-
"ok": True,
|
|
99
|
-
"action": "registered",
|
|
100
|
-
"reason": "",
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
except Exception as exc:
|
|
104
|
-
return {
|
|
105
|
-
"agent": agent,
|
|
106
|
-
"path": str(config_path),
|
|
107
|
-
"ok": False,
|
|
108
|
-
"action": "failed",
|
|
109
|
-
"reason": str(exc),
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
def register_all(self) -> list[dict]:
|
|
113
|
-
return [self.register(agent) for agent in _AGENTS]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|