diff --git a/AGENTS.md b/AGENTS.md index bd1ce48..f6ff36b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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)` — индекс; diff --git a/KNOWN_ISSUES.md b/KNOWN_ISSUES.md index 75db745..99b0455 100644 --- a/KNOWN_ISSUES.md +++ b/KNOWN_ISSUES.md @@ -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 (честно и предсказуемо). diff --git a/src/mcp/tools/codebase_tool.py b/src/mcp/tools/codebase_tool.py index 28f5e98..5c6a663 100644 --- a/src/mcp/tools/codebase_tool.py +++ b/src/mcp/tools/codebase_tool.py @@ -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.""" diff --git a/src/mcp/tools/write_tools.py b/src/mcp/tools/write_tools.py index b32316a..9d7cc36 100644 --- a/src/mcp/tools/write_tools.py +++ b/src/mcp/tools/write_tools.py @@ -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)) @@ -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: diff --git a/tests/test_codebase_hub.py b/tests/test_codebase_hub.py index 3fb1133..ef547d9 100644 --- a/tests/test_codebase_hub.py +++ b/tests/test_codebase_hub.py @@ -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 diff --git a/tests/test_write_tools.py b/tests/test_write_tools.py index b45ee5e..1167fcc 100644 --- a/tests/test_write_tools.py +++ b/tests/test_write_tools.py @@ -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