Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 12 additions & 6 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -335,12 +335,18 @@ Inline/Diagnostic (12): `debug_runtime_passport`, `intel_get_project_context`, `
`lsp_find_references(file_path, line, col)`, `lsp_find_definition(file_path, line, col)`,
`lsp_document_symbols(file_path)`, `lsp_get_type_info(file_path, line, col)`,
`lsp_get_diagnostics(file_path)`, `lsp_get_code_actions(file_path, line, col)`,
`codebase(action=...)`, `structural_search`, `get_repo_map`,
`get_repo_rank`, `get_hotspots`, `get_bug_correlation`,
`graph_query(action=query|cypher|related|flow)`, `detect_communities`,
`cross_repo_search`, `cross_project_deps`, `find_duplicates`,
`generate_chunk_summaries`, `scan_changes`, `find_similar_bugs`, `get_context`,
`verify_action`, `get_task_status`, `submit_background_task`, `stale_detector`.
`codebase(action=...)`, `structural_search`,
`graph_query(action=query|cypher|related|flow)`,
`submit_background_task`, `stale_detector`.

> **Not registered (consolidated):** the 14 names below are documented in older
> revisions but are **NOT exposed as MCP tools** in the current build — their
> functionality is covered by `search_code`, `get_symbol_info`, `impact_analysis`,
> and the `intel_*` suite. Do not call them; they return `tool not found`.
> `get_repo_map`, `get_repo_rank`, `get_hotspots`, `get_bug_correlation`,
> `detect_communities`, `cross_repo_search`, `cross_project_deps`,
> `find_duplicates`, `generate_chunk_summaries`, `scan_changes`,
> `find_similar_bugs`, `get_context`, `verify_action`, `get_task_status`.

> Hub-маршруты `codebase(action=...)` (не отдельные MCP-тулы):
> `codebase(action="index", path=status|progress|health|timeline|project_dir)` — индекс;
Expand Down
25 changes: 25 additions & 0 deletions KNOWN_ISSUES.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,31 @@
**Статус:** 🔴 наблюдается (блокирует завершение full reindex) | **Deadline:** следующая сессия | **Владелец:** misha.
**Note:** Новых реальных багов от фиксов A (logging deadlock) и B (off-by-one) НЕТ — оба live-верифицированы (embed-фаза без freeze; LanceDB 341/332). Finalization-hang — отдельный pre-existing инцидент индексатора, не связан с A/B.

## 2026-08-28 — Codebase hub write-actions: 4 бага (git routing, dry-run, move import, safe_delete) (OPEN / WATCHING)

**Что:** Live-верификация всех MCP-тулов (после перезапуска Zed, index READY 9193 chunks, job-суб-агенты) выявила 4 реальных бага в `codebase` hub (`src/mcp/tools/codebase_tool.py` / `server_tools.py`):
1. `codebase(action="git", path="log")` — `path="log"` уходит в filesystem-resolver → «Path does not exist: D:\Project\MSCodeBase\log» вместо git log (routing-баг).
2. `replace`/`insert_before`/`insert_after` с `apply=false` — dry-run НЕ соблюдается, лезут в write → `Permission denied` (guard не применён).
3. `move` генерирует невалидный import `from D:\.Project.MSCodeBase import ...` (raw drive + backslash вместо dotted module).
4. `safe_delete` недосчитывает usages: reports `usage count: 0`, тогда как `rename` корректно нашёл 2 call sites (`indexer.py:327,760` / `index_project_runner.py:473`) — сканирует только definitions, не callers.
**Fix (плановая задача mcp/tools):** (1) `git` action роутить на git-log, не path-resolver; (2) `apply=false` обязан предотвращать любой write (в т.ч. open для записи); (3) move import-rewrite — нормализовать путь в dotted module; (4) safe_delete считать callers через reference-index.
**Guard:** регресс-тесты на каждый case (dry-run nil-write; git log возвращает commits; move валидный import; safe_delete считает callers).
**Статус:** 🔴 наблюдается | **Deadline:** следующая сессия | **Владелец:** misha.

## 2026-08-28 — LSP-тулы: basedpyright не установлен (OPEN / ENV)

**Что:** `lsp_find_definition/references/document_symbols/get_diagnostics/get_type_info/get_code_actions` отвечают, но возвращают «basedpyright not found» — LSP-сервер не установлен в окружении. Сам MCP-тул слой исправен (проксирует ошибку LSP корректно).
**Fix:** установить basedpyright в venv расширения (`pip install basedpyright`) либо сконфигурировать LSP-провайдера; не блокирует продакшн (semantic search/graph работают).
**Guard:** smoke: lsp_find_definition на существующем символе возвращает позицию.
**Статус:** 🟡 env-gap | **Deadline:** — | **Владелец:** misha.

## 2026-08-28 — AGENTS.md §2 противоречит реальной регистрации тулов (CONTRADICTION, §4.9)

**Что:** §2 перечисляет 31 Core MCP-тул, но в РЕАЛЬНОМ сервере (RUN_ID ad89b2d4) НЕ зарегистрированы 14: `get_repo_map`, `get_repo_rank`, `get_hotspots`, `get_bug_correlation`, `detect_communities`, `cross_repo_search`, `cross_project_deps`, `find_duplicates`, `generate_chunk_summaries`, `scan_changes`, `find_similar_bugs`, `get_context`, `verify_action`, `get_task_status`. Ни суб-агент, ни Orchestrator не могут их вызвать (tool-not-found). Док-контрадикция (§4.9): док «доступно», рантайм — нет.
**Fix:** либо зарегистрировать эти тулы в `server_tools.py`/`tools_reg.py` (если задумывались), либо убрать из §2 (если deprecated). Сверить с `📐 MCP Tools: N/M` логом старта.
**Guard:** систематический cross-check (§6.5): grep имён тулов §2 против реальной регистрации.
**Статус:** 🔴 наблюдается | **Deadline:** следующая сессия | **Владелец:** misha.

## 2026-08-27 — Data Gap: папка tests/ не индексируется Tree-sitter AST (OPEN / Planned)

**Что:** E4.1-бенчмарк (`experiments/bench_e4_1.py`) показал Recall=0.00 на классах `test`/`verify` НЕ из-за бага алгоритма/сериализации, а потому что реальные файлы `tests/test_*.py` НЕ присутствуют в PropertyGraph (`graph.db` содержит только `tests/fixtures/sample_module.py` — 270 узлов из 10768). `SymbolIndexAdapter.search_symbols` детерминированно возвращает `[]` на символы, которых нет в базе — свойство Proof of Origin (честно и предсказуемо).
Expand Down
41 changes: 36 additions & 5 deletions src/mcp/tools/codebase_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -232,12 +232,43 @@ async def _action_set_project(self, **kw) -> str:
return await SetProjectTool(self._services).execute(project_root=target)

async def _action_git(self, **kw) -> str | dict[str, Any]:
"""Git operations — делегирует в GetCommitHistoryTool."""
from src.mcp.tools.git_tools import GetCommitHistoryTool
"""Git operations — делегирует в git-tools по subcommand (kw.path).

path = kw.get("path", ".")
gt = GetCommitHistoryTool(self._services)
return await gt.execute(project_root=path, limit=kw.get("max_count", 10))
path: "log"|"history" -> GetCommitHistoryTool
"branch" -> GetBranchInfoTool
"file" -> GetFileHistoryTool (требует file_path)
project_root берётся из kw.project_root или resolve_indexer()
(НЕ из path — path это git subcommand, а не путь ФС).
"""
from src.mcp.tools.git_tools import (
GetBranchInfoTool,
GetCommitHistoryTool,
GetFileHistoryTool,
)

sub = (kw.get("path") or ".").strip().lower()
limit = int(kw.get("max_count", kw.get("limit", 10)))
try:
project_root = kw.get("project_root") or str(self.resolve_indexer().project_path)
except Exception:
project_root = str(Path.cwd())

if sub in ("", ".", "log", "history", "commits"):
gt = GetCommitHistoryTool(self._services)
return await gt.execute(project_root=project_root, limit=limit)
if sub == "branch":
gt = GetBranchInfoTool(self._services)
return await gt.execute(project_root=project_root)
if sub == "file":
file_path = kw.get("file_path", "")
if not file_path:
return {"status": "error", "message": "file_path is required for git action 'file'."}
gt = GetFileHistoryTool(self._services)
return await gt.execute(project_root=project_root, file_path=file_path)
return {
"status": "error",
"message": f"Unknown git sub-action '{sub}'. Use one of: log, history, branch, file.",
}

async def _action_system(self, **kw) -> str:
"""System operations — делегирует в SystemTool."""
Expand Down
15 changes: 10 additions & 5 deletions src/mcp/tools/write_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -420,7 +420,7 @@ async def _action_safe_delete(self, **kw):
target = Path(file_path).resolve().as_posix()
defs = [d for d in defs if Path(d.file_path).resolve().as_posix() == target]

all_refs = si.find_references(symbol)
all_refs = si.find_all_references(symbol)
usages = [r for r in all_refs if not r.is_definition and r.symbol == symbol]
if usages and not force:
usage_files = list(set(r.file_path for r in usages))
Expand Down Expand Up @@ -639,10 +639,15 @@ def _build_changes(self, old_name: str, new_name: str, refs: list) -> List[Dict]

def _infer_package(self, file_path: str) -> str:
p = Path(file_path).resolve()
stem = p.stem # e.g. "foo.py" → "foo"
parts = list(p.parent.parts)
if stem:
parts.append(stem)
# Корень проекта нужен, чтобы генерировать dotted-импорт
# относительно проекта, а не абсолютный Windows-путь
# (иначе получаем невалидный `from D:\.Project... import X`).
try:
root = Path(self.resolve_indexer().project_path).resolve()
rel = p.relative_to(root)
except Exception:
rel = p
parts = list(rel.with_suffix("").parts)
return ".".join(pt for pt in parts if pt)

def _find_body_end(self, lines: list, def_line: int) -> int:
Expand Down
75 changes: 75 additions & 0 deletions tests/test_codebase_hub.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,4 +110,79 @@ async def test_impact_token_passthrough(self, hub):
async def test_unknown_subaction_returns_helpful_error(self, hub):
result = await hub.execute(action="write")
assert "Не удалось определить" in result


class TestHubGitRouting:
"""Regression (bug #1): codebase(action='git', path='log') must pass the
real project_root to the git tool, NOT the subcommand string 'log'
(which previously produced 'Path does not exist: .../log').

NOTE: hub.execute is wrapped by @error_boundary/MCP layer, so it returns a
formatted STRING, not the raw dict. We assert on captured project_root
(the actual fix) and on string content.
"""

@pytest.mark.asyncio
async def test_git_log_passes_real_project_root(self, mock_services, tmp_path, monkeypatch):
captured = {}

class FakeCommitHistory:
def __init__(self, services):
self.services = services

async def execute(self, project_root="", limit=10, **kw):
captured["project_root"] = project_root
captured["limit"] = limit
return {"status": "ok", "commits": [], "total_commits_in_history": 0, "displayed": 0, "authors": {}}

# NOTE: _action_git imports from git_tools inside the function, so patch there.
monkeypatch.setattr("src.mcp.tools.git_tools.GetCommitHistoryTool", FakeCommitHistory)

idx = MagicMock()
idx.project_path = str(tmp_path)
hub = CodebaseTool(mock_services)
hub.resolve_indexer = MagicMock(return_value=idx)

result = await hub.execute(action="git", path="log")
assert captured.get("project_root") == str(tmp_path), (
f"git log must pass real project_root, got {captured.get('project_root')!r}"
)
assert isinstance(result, str)
assert "Completed" in result

@pytest.mark.asyncio
async def test_git_branch_routes_to_branch_tool(self, mock_services, tmp_path, monkeypatch):
captured = {}

class FakeBranch:
def __init__(self, services):
self.services = services

async def execute(self, project_root="", **kw):
captured["project_root"] = project_root
return {"status": "ok", "branch": "main", "index_exists": True, "total_chunks": 0}

monkeypatch.setattr("src.mcp.tools.git_tools.GetBranchInfoTool", FakeBranch)

idx = MagicMock()
idx.project_path = str(tmp_path)
hub = CodebaseTool(mock_services)
hub.resolve_indexer = MagicMock(return_value=idx)

result = await hub.execute(action="git", path="branch")
assert captured.get("project_root") == str(tmp_path)
assert isinstance(result, str)
assert "main" in result

@pytest.mark.asyncio
async def test_git_unknown_subaction_errors(self, mock_services, tmp_path, monkeypatch):
idx = MagicMock()
idx.project_path = str(tmp_path)
hub = CodebaseTool(mock_services)
hub.resolve_indexer = MagicMock(return_value=idx)
result = await hub.execute(action="git", path="frobnicate")
assert isinstance(result, str)
# Bug #1 fix: unknown subcommand must NOT be treated as a filesystem path
# (previously: "Path does not exist: .../frobnicate").
assert "Unknown git sub-action" in result
assert FakeWriteTool.last_action is None
78 changes: 78 additions & 0 deletions tests/test_write_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -1152,3 +1152,81 @@ async def test_preview_insert_with_check_types(self, write_tool, tmp_path):
assert "Preflight" in result
assert "LSP" in result
assert f.read_text() == before # запись не производилась


class TestInferPackage:
"""Regression (bug #3): _infer_package must produce a valid dotted
module relative to the project root, NOT a corrupt Windows absolute
path like `D:\\.Project.MSCodeBase...`."""

def test_returns_dotted_module_relative_to_root(self, write_tool, tmp_path):
idx = _make_mock_indexer()
idx.project_path = str(tmp_path)
write_tool.resolve_indexer = MagicMock(return_value=idx)

target = tmp_path / "src" / "core" / "indexing" / "index_guard.py"
target.parent.mkdir(parents=True, exist_ok=True)
pkg = write_tool._infer_package(str(target))

assert pkg == "src.core.indexing.index_guard"
assert "\\" not in pkg
assert ":" not in pkg


class TestSafeDeleteCallers:
"""Regression (bug #4): safe_delete must count cross-file call sites
(usages), consistent with rename's reference lookup."""

@pytest.mark.asyncio
async def test_counts_cross_file_callers(self, write_tool, tmp_path):
from src.core.indexing.symbol_index import SymbolIndex, SymbolRef

si = SymbolIndex()
si.add_definitions(str(tmp_path / "mod.py"), [
{"name": "target_func", "line": 1, "kind": "function"},
])
si._references["target_func"] = [
SymbolRef(symbol="target_func", file_path=str(tmp_path / "caller_a.py"), line=10, kind="call", is_definition=False),
SymbolRef(symbol="target_func", file_path=str(tmp_path / "caller_b.py"), line=20, kind="call", is_definition=False),
]
write_tool.resolve_symbol_index = MagicMock(return_value=si)
idx = _make_mock_indexer()
idx.project_path = str(tmp_path)
write_tool.resolve_indexer = MagicMock(return_value=idx)

result = await write_tool._action_safe_delete(
symbol="target_func", file_path="", apply=False, force=False
)
assert result["status"] == "denied"
assert result["usage_count"] == 2


class TestDryRunNoWrite:
"""Regression (bug #2): apply=False must never write to disk."""

@pytest.mark.asyncio
async def test_replace_dry_run_does_not_write(self, write_tool, tmp_path, monkeypatch):
from src.mcp.tools import write_tools as wt_module

src = tmp_path / "m.py"
src.write_text("def foo():\n return 1\n")
si = _build_index_for_file(
src,
extra_defs=[{"name": "foo", "line": 1, "kind": "function"}],
add_refs=False,
)
write_tool.resolve_symbol_index = MagicMock(return_value=si)
idx = _make_mock_indexer()
idx.project_path = str(tmp_path)
write_tool.resolve_indexer = MagicMock(return_value=idx)

spy = MagicMock()
monkeypatch.setattr(wt_module, "_atomic_write", spy)

result = await write_tool._action_replace(
symbol="foo",
new_code="def foo():\n return 2\n",
apply=False,
)
spy.assert_not_called()
assert "preview" in result.lower() or "🔍" in result
Loading