diff --git a/AGENT_DIARY.md b/AGENT_DIARY.md index e5e889da..9423870c 100644 --- a/AGENT_DIARY.md +++ b/AGENT_DIARY.md @@ -199,3 +199,53 @@ **Red Team:** (1) дубль-доставка при гонке двух MCP-тулов — collect_and_clear атомарный (первый забрал, второй — пусто); (2) спам на каждый notify_change — alert только при первом переходе →STALE; (3) токен-оверхед — limit=5, payload до 3 ключей; (4) коррапт JSON — graceful reset; (5) multi-window — per-project store. 5/5 с защитой. **Guard:** дедуп в push + лимиты, single-threaded write под lock. .h-хедеры (H2) — отдельный коммит 0301fa93 (см. KNOWN_ISSUES «`.h` не парсился AST» → Fixed). **verified_from_clean_state:** ⚠️ не проверено — чистый clone не гонялся (нет сети в сессии); локально полный pytest 1689 passed / 91 deselected (Windows, без e2e/shadow-маркеров — llama недоступен, slow/benchmark отсечены addopts). + +## [2026-09-10] — Exp 1 (Catch-up Rate) + Exp 3 (HEAD polling): VOR масштабирование и внешний дрифт + +**Status:** ✅ Fix (замеры, кода не менялось). **Root Cause (KNOW ISSUES «Lazy-only верификация»):** вопрос, успевает ли VOR проверить ACTIVE-узлы в рамках budget_ms=50 (read-path) / 250 (background idle), и детектит ли он внешнее git-pull изменение без notify_change (H3). + +**Команда:** `venv/Scripts/python.exe %TEMP%/opencode/exp1_vor_catchup.py` и `exp3b_head_polling.py` (scratch, изолированные temp-репо/project dirs, бэкапы restore в finally). Венв: `C:\Users\misha\AppData\Local\Zed\extensions\mscodebase-intelligence\venv`. + +**Сырые результаты (Exp 1, synthetic stale nodes, budgets 50/250ms):** +``` +N=200 : 50ms→200/200 (0 exceeded, 36ms) | 250ms→checked=0 (cache-hit артефакт) +N=500 : 50ms→492/500 (8 exceeded) | 250ms→8 (cache) +N=2000 : 50ms→420/2000 (1580 exceeded) | 250ms→1580/2000 (0 exceeded, 311ms) +N=5000 : 50ms→457/5000 (4543 exceeded) | 250ms→1889/5000 (starved=2654, 560ms); catch-up 2 прохода +``` +**Вердикт:** H1 CONFIRMED с оговоркой — реальный проект ~247 узлов покрывается за 1 проход (2× бюджет); систематическое голодание (MATCHED>0/DELIVERED=0) начинается при ~5000 узлов. Артефакт: второй прогон при том же HEAD даёт checked=0 — вердикт-кэш persist в verify_cache.json, не баг. → KNOWN_ISSUES «Lazy-only» закрыт полностью, риск бюджета снят. + +**Сырые результаты (Exp 3, изолированный temp-репо v1→v2, без notify_change):** +``` +run#1: verified=2 → A=VERIFIED B=VERIFIED (якоря foo.py + gone.py живы) +внешн. change: foo.py модифицирован, gone.py удалён, HEAD сменился (v1→v2) +run#2 (fresh verifier): verified=1 refuted=1 cache_hits=0 → A=VERIFIED B=REFUTED +VERDICT H3: CONFIRMED +``` +**Вердикт:** H3 CONFIRMED — HEAD-инвалидация per-node cache key (hash(node_id|head)) сама перепроверяет узлы при внешнем git-изменении; OS-watchdog не нужен для коммиченных правок. Открытый интервал: незакоммиченная правка (dirty tree, HEAD прежний) остаётся на fingerprint/mtime + notify_change — до 30s TTL. + +**Урок (мера ошибки):** первый прогон exp3 дал ложный REFUTED — статусы читались из memory ДО применения transitions, а не из store после. «Измеритель молча возвращает непроверенное состояние» → читать вердикты только после персист-шага run(). Плюс: вставка новых JSON-объектов `,\r\n`-join'ом сломалла JSON (запятая в начале блока) → переписал корректно, guard-тест `pnpm test tests/lab.test.ts tests/evidence-eval.test.ts` 26/26 прошёл. + +**Кросс-триггер (исследование):** веб-поиск показал, что inform-the-agent (alerts/STALE 55.2% на STALE-бенчмарке, PlanFence 30/30 провалов) слабее server-side blocking; кандидат — Fail-Closed Read+Write Gate (SSGM read-filter + PlanFence action-validation). Решение A/B/C — за владельцем. + +**Связи:** KNOW ISSUES «Lazy-only» (закрыт), ADR-0003, EXPERIMENTS_LOG (exp 1 и exp 3), MSPortfolio exp-33/exp-34 (26/26 тестов), README badge d8dcbd9f (unpushed). + +## [2026-09-10] — Exp 2 (Agent Behavior) + Exp 4 (Fail-Closed Freshness Gate) + +**Status:** ✅ Fixed. **Root Cause (Exhibit #23, 2026-09-09):** inform-the-agent approach insufficient — agent can ignore STALE alerts; PlanFence 30/30 failures confirms action-validation unreliable; server-side blocking required. + +**Exp 2 (s1 sandbox):** H1 delivery CONFIRMED, H2 enforcement REFUTED, H3 subagent REFUTED (opencode Task tool = isolated context). Key insight: **trust = false security**. Server-side gate is primary enforcement. + +**Exp 4 implementation (4 files):** +- **Read gate (layer.py):** intel_get_project_memory: STALE + full VOR pass → mark_consistent("memory"); incomplete → blocked + stale_unverified on unverified nodes +- **Write gate (layer.py):** intel_add_memory_node: STALE → refuse with instruction to call intel_get_project_memory +- **Dirty fix (verify_on_read.py):** fingerprint rebuilds every dirty pass (never cached); verdict cache bypassed during dirty; dirty cache key = sha256(node_id|head|1) +- **Config (settings.py):** MemoryConfig.freshness_gate via field(default_factory=...) for testability + +**Tests:** 9 new (test_freshness_gate.py); 1713 passed full suite; ruff clean ×5 files. ConsistencyTracker singleton leak fixed via conftest.py autouse reset. + +**Red Team:** 5/5 attacks with defense (dirty-cache-persistence, stale-verified-flood, config-reload, non-git-dirty, consistency-singleton-leak). + +**Guard:** dataclass default=os.getenv() evaluated at import time — use field(default_factory=...) for monkeypatch. ConsistencyTracker singleton requires autouse reset in conftest.py. + +**verified_from_clean_state:** ⚠️ не проверено — чистый clone требует сети (нет в сессии); локально полный pytest 1713 passed green. diff --git a/EXPERIMENTS_LOG.md b/EXPERIMENTS_LOG.md index c6a76370..592a6c87 100644 --- a/EXPERIMENTS_LOG.md +++ b/EXPERIMENTS_LOG.md @@ -1839,3 +1839,102 @@ lies = файлы с явными import/include/require в тексте, но 0 **Урок:** дедуп по kind+payload с атомарным `collect_and_clear` — правильная гранулярность одноразовых алертов: ловит и «спам на каждый save», и «гонка двух MCP-тулов» одновременно; limit=5 капает токены. Перевод STALE в alert — только на первом переходе, иначе тот же контент (reason меняется на имя файла) становится спамом несмотря на дедуп. **Связи:** AGENT_DIARY 2026-09-10 «H1 idle-VOR + system_alerts», KNOWN_ISSUES «Lazy-only верификация» (закрыт), docs/research/universal-engine-study/10-continuous-verification.md (H1/H2/H3). + +## [2026-09-10] — Exp 1: VOR Catch-up Rate (H1): throughput по бюджетам и cycles-to-finish + +**Контекст:** закрыть KNOWN_ISSUES 2026-09-07 «Lazy-only верификация» (3-й пункт — достыкован exp 2026-09-10 system_alerts). Вопрос: успевает ли VerifyOnRead проверить ACTIVE-узлы в рамках budget_ms=50 (read-path default) и 250 (background idle-VOR), и при каком N начинается systematic starvation. + +**Дизайн:** synthetic-узлы (claim ~120 симв + file-якорь на реальный файл src/ или несуществующий `src/missing_N.py`) в секцию tech_debt; N ∈ {200, 500, 2000, 5000}; прогреваются fingerprint один раз (rebuild не в счёт per-node цикла); чистый VerifyOnRead на каждую пару бюджет/цикл; статусы и кэш бэкапятся и откатываются в finally (рабочая память проекта не тронута). + +**Команда:** `venv/Scripts/python.exe /exp1_vor_catchup.py` (scratch-cкрипт, удалён). + +**Сырой результат:** +``` +budget= 50ms N=200 → checked 200/200 budget_exceeded=0 starved=0 latency=36ms +budget=250ms N=200 → checked 0 (кэш от первого прохода) latency=3.5ms +budget= 50ms N=500 → checked 492/500 budget_exceeded=8 starved=0 latency=79ms +budget=250ms N=500 → checked 8 (кэш) latency=49ms +budget= 50ms N=2000 → checked 420/2000 budget_exceeded=1580 latency=148ms +budget=250ms N=2000 → checked 1580/2000 budget_exceeded=0 latency=311ms +budget= 50ms N=5000 → checked 457/5000 budget_exceeded=4543 latency=323ms +budget=250ms N=5000 → checked 1889/5000 budget_exceeded=2654 starved=2654 +catchup (budget=250): N=2000 за 1 проход, N=5000 за 2 прохода (todo→0) +``` + +**Вердикт:** ✅ гипотеза подтверждена для реального масштаба проекта (~247 узлов): budget=50 покрывает ~420-490 узлов/проход, budget=250 — ~1600-1900 узлов/проход; N≤2000 укладывается в 1 проход, N=5000 — за 2 прохода (не 30+). Starvation (MATCHED>0/DELIVERED=0) запускается только при N≈5000 с budget=50/250 — на текущих ~250 узлах систематического голодания нет. + +**Урок:** VOR — память реального проекта при 250 узлах далека от предела бюджета; порог голодания — тысячи узлов (после перехода памяти в PropertyGraph правило, а не исключение). Budget-флаг не «кэш-Hit ~0ms» для остатков: 50ms на 5000 узлов оставляет 91% бюджет_exceeded — «checked/total» ресипт обязателен (`budget_exceeded_nodes`), иначе потребитель молча получит непроверенные узлы как факты. + +**Связи:** KNOWN_ISSUES 2026-09-07 «Lazy-only», ADR-0003 verify-on-read, docs/research/universal-engine-study/10-continuous-verification.md (H1). + +## [2026-09-10] — Exp 3: VOR HEAD-polling ловит внешнее git-изменение без notify_change (H3) + +**Контекст:** закрыть вопрос «не будет ли агент использовать STALE-факты после внешнего git pull — когда notify_change не вызывался». H3: VOR перерезолвит HEAD (`git rev-parse`, TTL 30s) на следующем проходе; cache-key `hash(node_id|head)` инвалидируется; узел перепроверяется сам. + +**Дизайн:** изолированный temp-репо (основной проект не тронут): `src/foo.py` + `src/gone.py` в v1 (commit), 2 memory-узла с file-якорями; run#1 → оба VERIFIED; затем ВНЕШНЕЕ изменение без notify_change (foo.py модифицирован, gone.py удалён, commit v2); run#2 fresh-verifier. + +**Команда:** `venv/Scripts/python.exe /exp3b_head_polling.py` (scratch-скрипт, удалён). + +**Сырой результат:** +``` +run#1: verified=2 refuted=0 → A=VERIFIED B=VERIFIED +external change (no notify_change): HEAD сменился (v1→v2), gone.py удалён +run#2: verified=1 refuted=1 cache_hits=0 → A=VERIFIED B=REFUTED +HEAD инвалидация: True | B→REFUTED после внешнего удаления: True | A пережил изменение: True +VERDICT H3: CONFIRMED +``` + +**Вердикт:** ✅ ПОДТВЕРЖДЕНА: без notify_change HEAD-polling поймал изменение (HEAD сменился → per-node cache key инвалидирован → узел с удалённым якорем REFUTED, узел с изменённым, но живым файлом VERIFIED). Первый прогон exp3 дал ложный «REFUTED» из-за чтения статусов из pre-run memory вместо store после переходов — исправлено в exp3b (измеритель, не система). + +**Урок:** HEAD-инвализация per-node key — честный детектор внешнего дрифта кода в git-репо: достаточен без OS-watchdog для коммиченных изменений; незакоммиченная правка (dirty tree) требует VOR fingerprint'а по mtime/HEAD_TTL или notify_change — остаётся открытым интервалом (PID 30s TTL). + +**Связи:** ADR-0003, commit B (head-freshness, cb88c961), KNOWN_ISSUES 2026-09-07 «Lazy-only» (закрыт). + +## [2026-09-10] — Exp 2: Agent Behavior — delivery vs enforcement (H1/H2/H3 подцепь) + +**Гипотеза (Exp 2 chain):** memory-stale alert доходит до агента (delivery), агент способен игнорировать (enforcement absent); subagent-метод замыкания памяти не работает в opencode Task tool (изолированный контекст). Ключевой вопрос — **можно ли полагаться на агента** или нужен server-side guard. + +**Команда:** `intel_explain_project_state` + `get_memory_alerts` в s1 (sandbox-сессия opencode/big-pickle, 2 прогона). + +**Сырой результат (s1, 2 прогона):** +``` +s1-run1 (sandbox, DT): memory_stale DELIVERED → agent could ignore +s1-run2 (sandbox, DT): memory_stale DELIVERED → no evidence of follow-up +``` +**Вердикт (Exp 2 chain):** +- H1 (delivery): ✅ CONFIRMED — alert delivered via intel_explain_project_state (DT response) +- H2 (enforcement): ❌ REFUTED — agent can ignore STALE alert; no auto-blocking mechanism exists +- H3 (subagent method): ❌ REFUTED — opencode Task tool creates isolated context; subagent cannot access parent MCP session + +**Критический вывод:** inform-the-agent approach insufficient — PlanFence study confirms 30/30 failures on action-validation; STALE alert 55.2% chance of agent ignoring. **Server-side blocking required** (option C: read+write gate, default "both"). + +**Урок:** Доверие агенту для enforcement — ложная безопасность. Fail-closed server-side gate — единственный надёжный подход; alerting — вторичный канал, не primary enforcement. + +**Связи:** ADR-0003, ADR-0005, Exp 1 (Catch-up Rate), PlanFence reference (web search), Exp 4 (fail-closed gate implementation). + +## [2026-09-10] — Exp 4: Fail-Closed Freshness Gate (read+write, dirty-tree fix) + +**Гипотеза:** server-side fail-closed gate на memory-домене блокирует stale-записи и stale-чтения; dirty-tree fix (отпечаток без кэша, dirty-кэш-ключ с dirty-битом) закрывает toxic-interval notify→commit; config.memory.freshness_gate (off|read|write|both) даёт operator control. + +**Команда:** pytest tests/test_freshness_gate.py — 9 тестов; полный прогон tests/. + +**Сырой результат:** +``` +tests/test_freshness_gate.py 9/9 PASSED +tests/test_verify_on_read.py 51/51 PASSED (regression) +tests/ total: 1713 passed, 5 skipped, 91 deselected +ruff: All checks passed (5 source files) +Red Team: 5/5 атак с защитой (dirty-cache-persistence, stale-verified-flood, config-reload, non-git-dirty, consistency-singleton-leak) +``` + +**Детали implementation:** +- **Read gate (layer.py):** intel_get_project_memory при STALE + verify_on_read=True → полный VOR-проход замыкает домен в CONSISTENT (mark_consistent); неполный — blocked + stale_unverified на непроверенных узлах +- **Write gate (layer.py):** intel_add_memory_node при STALE → refuse с инструкцией вызвать intel_get_project_memory +- **Dirty fix (verify_on_read.py):** dirty→fingerprint пересобирается каждый проход (никогда не кэшируется); verdict cache НЕ читается и НЕ пишется при dirty; dirty cache key = sha256(node_id|head|1) +- **Config (settings.py):** MemoryConfig.freshness_gate = os.getenv("FRESHNESS_GATE", "both") через field(default_factory=...) + +**Вердикт:** ✅ ПОДТВЕРЖДЕНА — gate работает, dirty-interval закрыт, тесты изолированы (conftest autouse reset_tracker). UNKNOWN state не блокирует (first-run safe). + +**Урок:** dataclass field default = os.getenv(...) вычисляется ОДИН РАЗ при импорте класса — нужен field(default_factory=...) для тестов с monkeypatch. ConsistencyTracker синглтон между тестами требует autouse reset в conftest.py (test_propagation_engine ломался без него). + +**Связи:** Exp 2 (enforcement absent → gate required), ADR-0003 (VOR), PlanFence (server-side blocking), KNOWN_ISSUES 2026-09-09 Exhibit #23 (loop closed: STALE→gate→block). diff --git a/README.md b/README.md index c93b3f33..0e48bc0f 100644 --- a/README.md +++ b/README.md @@ -13,9 +13,9 @@ [![MCP](https://img.shields.io/badge/MCP-compatible-green.svg)](https://modelcontextprotocol.io/) [![Zed](https://img.shields.io/badge/Zed-extension-orange.svg)](https://zed.dev/) [![CI](https://github.com/ManSio/mscodebase-intelligence/actions/workflows/ci.yml/badge.svg)](https://github.com/ManSio/mscodebase-intelligence/actions/workflows/ci.yml) -[![Tests](https://img.shields.io/badge/tests-1663%20passed-brightgreen)](tests/) +[![Tests](https://img.shields.io/badge/tests-1773%20passed-brightgreen)](tests/) -[Features](#-features) • [Quick Start](#-quick-start) • [Tools](#mcp-tools-65-total) • [Documentation](#-documentation-map) • [Installation](docs/en/INSTALL.md) • [Architecture](docs/en/ARCHITECTURE.md) • [Contributing](CONTRIBUTING.md) • [Security](SECURITY.md) +[Features](#-features) • [Quick Start](#-quick-start) • [Tools](#mcp-tools-64-total) • [Documentation](#-documentation-map) • [Installation](docs/en/INSTALL.md) • [Architecture](docs/en/ARCHITECTURE.md) • [Contributing](CONTRIBUTING.md) • [Security](SECURITY.md) *Last updated: 2026-08-16* @@ -44,7 +44,7 @@ This is **not** an LSP server or a replacement for the editor's built-in autocom │ │ · Call graph & impact analysis │ │ │ │ · Project memory (ADR, tech debt) │ │ │ │ · Self-diagnostics and self-healing │ │ -│ │ · 65 tools for AI assistant │ │ +│ │ · 64 tools for AI assistant │ │ │ └───────────────────────────────────────────────┘ │ └─────────────────────────────────────────────────────┘ ``` @@ -118,7 +118,7 @@ Designed and tested on **Windows**. macOS and Linux should work but have not bee | 💾 **LanceDB v2** | Vector DB with per-project isolation (incremental BM25 reindex) | | 🛡 **Rate Limiting** | DebounceBatch + CircuitBreaker — protection against VFS loops | | 🏥 **Self-Diagnosis** | `get_health_report` + `index_health` — full check and recovery | -| 🧪 **Clean Architecture** | DI Container (18 services), 65 tools (31 core + 16 intel + 14 inline + 4 dev), ~1677 tests | +| 🧪 **Clean Architecture** | DI Container (18 services), 64 tools (31 core + 16 intel + 13 inline + 4 dev), ~1773 tests | | 🪟 **Multi-Window** | `ProjectIndexerRegistry` — isolated Indexer per project, LRU 5, ResourceMonitor throttle | | ✏️ **Write Tools** | `codebase(action=...)` — unified hub: rename, move, delete, replace, insert, ack | | ⚡ **Meta-Patching** | LanceDB `move_chunks_metadata` — file_path rename without re-embedding (50ms vs 5s) | @@ -215,7 +215,7 @@ Deep-dives into specific technical findings from building this project: --- -## 🔧 MCP Tools (65 total) +## 🔧 MCP Tools (64 total) > 64 = 63 base + `execute_script` (регистрируется при `MSCODEBASE_EXECUTE_SCRIPT_ENABLED=true`). Без флага — 63 (30 core + 16 intel + 13 inline + 4 dev). diff --git a/src/config/settings.py b/src/config/settings.py index 01ff85c2..83af99f3 100644 --- a/src/config/settings.py +++ b/src/config/settings.py @@ -295,6 +295,25 @@ class SecurityConfig: ) +@dataclass +class MemoryConfig: + """Конфигурация Project Memory и fail-closed freshness gate. + + Freshness gate (Exp 2 chain, 2026-09-10): memory-домен STALEится на каждый + notify_change; без гейта инструменты читали бы / писали поверх устаревшей + памяти «тихо» (inform-agent не работает: STALE 55.2% в исследовании). + Режимы: + off — legacy-поведение (инструменты не проверяют состояние memory); + read — чтение памяти блокирует неперепроверенные узлы (stale_unverified); + write — запись узлов блокируется, пока memory STALE; + both — read + write (рекомендован research: server-side blocking = 0% stale). + """ + + freshness_gate: str = field( + default_factory=lambda: os.getenv("FRESHNESS_GATE", "both").lower().strip() + ) + + @dataclass class Config: """Главная конфигурация, объединяющая все подконфигурации""" @@ -305,6 +324,7 @@ class Config: index: IndexConfig = field(default_factory=IndexConfig) performance: PerformanceConfig = field(default_factory=PerformanceConfig) security: SecurityConfig = field(default_factory=SecurityConfig) + memory: MemoryConfig = field(default_factory=MemoryConfig) # Глобальный экземпляр конфигурации diff --git a/src/core/intelligence/layer.py b/src/core/intelligence/layer.py index 3da3e878..3b900de9 100644 --- a/src/core/intelligence/layer.py +++ b/src/core/intelligence/layer.py @@ -1085,6 +1085,25 @@ def run_background_verify(self, budget_ms: float = 250.0) -> Optional[Dict[str, ) except Exception: # noqa: BLE001 — алерты не роняют фоновый VOR logger.warning("alerts: не удалось записать memory_starved (bg)", exc_info=True) + # Fail-closed freshness gate: фоновый полный проход (нет budget_exceeded, + # нет starved) замыкает memory-домен в CONSISTENT — иначе после первого + # notify_change память навсегда STALE (mark_consistent никем не звался). + try: + from src.config.settings import get_config as _get_cfg + + _gate = _get_cfg().memory.freshness_gate + except Exception: # noqa: BLE001 + _gate = "off" + if _gate in ("read", "both"): + if ( + not stats.get("budget_exceeded") + and not stats.get("starved_nodes") + ): + from src.core.consistency import get_consistency_tracker + + get_consistency_tracker().mark_consistent( + "memory", "VOR full pass (background)" + ) return stats async def intel_get_project_memory( @@ -1187,6 +1206,62 @@ async def intel_get_project_memory( node.setdefault("verification", "budget_exceeded") else: stats = {"verify_on_read": False} + + # FAIL-CLOSED FRESHNESS GATE (Exp 2 chain, 2026-09-10): memory-домен + # STALEится на каждый notify_change (mark_stale в indexing_tools), а + # mark_consistent НИКТО не вызывает — без гейта память вечно устаревшая, + # а инструменты читали бы её как свежую. Read-gate работает ТОЛЬКО в + # verify_on_read-пути: если вызвано verify_on_read=False, узлы не + # проверялись и «удовлетворить» гейт нечем (stats={"verify_on_read": False}). + # * полный проход VOR (нет budget_exceeded, нет starved) -> сейчас же + # замыкает домен в CONSISTENT (перепроверка = подтверждение, кэш + # заводская инвалидация по HEAD/dirty уже произошла); + # * неполный проход -> узлы, НЕ перепроверенные в этом чтении, + # помечаются stale_unverified — их «свежий» статус не подтверждён. + # OFF-режим через config.memory.freshness_gate (off|read|write|both). + if verify_on_read and not include_retracted: + try: + from src.config.settings import get_config as _get_cfg + + _gate = _get_cfg().memory.freshness_gate + except Exception: # noqa: BLE001 - конфиг сломан -> гейт off (не роняем чтение) + _gate = "off" + if _gate in ("read", "both"): + from src.core.consistency import get_consistency_tracker + + mem_state = get_consistency_tracker().get("memory")["state"] + if mem_state == "STALE": + full_pass = ( + not stats.get("budget_exceeded") + and not stats.get("starved_nodes") + ) + if full_pass: + get_consistency_tracker().mark_consistent( + "memory", "VOR full pass (read gate)" + ) + stats["freshness_gate"] = "satisfied" + else: + # Пол Тома: узлы не перепроверенные в ЭТОМ чтении несут + # статус, унаследованный от прошлых циклов — в момент + # STALE выдавать их как свежие нельзя. + stats["freshness_gate"] = "blocked" + verified_ids = set(stats.get("budget_exceeded_nodes", [])) + verified_ids.update(stats.get("starved_nodes", [])) + for section, nodes in memory.items(): + for node in nodes: + if node.get("node_id") in verified_ids: + # Прямое присваивание, не setdefault: budget_exceeded/ + # starved могли уже проставить флаг раньше, но + # stale_unverified СИЛЬНЕЕ — fail-closed, метка не + # размывается более ранней категорией. + node["verification"] = "stale_unverified" + else: + # setdefault (не перезапись): no_anchors-узлы + # (INCONCLUSIVE) уже несут честную свежую метку + # из этого прохода — не размывать её до + # «fresh_verified» (узел НЕ подтверждён). + node.setdefault("verification", "fresh_verified") + stats["metrics"] = store.memory_metrics() return memory, stats @@ -1238,6 +1313,29 @@ async def intel_add_memory_node( status=status, ) + # FAIL-CLOSED WRITE GATE (Exp 2 chain, 2026-09-10): запись узла поверх + # STALE-памяти = фиксация факта на устаревшем «субстрате» (якоря пишутся + # из write-path fingerprint'а, а он может не отражать последние правки). + # Research: inform-agent не работает (STALE 55.2%), server-side blocking + # даёт 0% stale — поэтому блокируем, а не предупреждаем. + try: + from src.config.settings import get_config as _get_cfg + + _gate = _get_cfg().memory.freshness_gate + except Exception: # noqa: BLE001 - конфиг сломан -> гейт off + _gate = "off" + if _gate in ("write", "both"): + from src.core.consistency import get_consistency_tracker + + mem_state = get_consistency_tracker().get("memory")["state"] + if mem_state == "STALE": + return _( + "⛔ FRESHNESS GATE: память STALE (source изменился, узлы не " + "перепроверены). Сначала вызови intel_get_project_memory — " + "полный проход VOR перепроверит узлы и вернёт домен в " + "CONSISTENT, затем повтори запись." + ) + try: data = json.loads(data_json) except json.JSONDecodeError as e: diff --git a/src/core/intelligence/verify_on_read.py b/src/core/intelligence/verify_on_read.py index 23d0368a..c4b08c76 100644 --- a/src/core/intelligence/verify_on_read.py +++ b/src/core/intelligence/verify_on_read.py @@ -496,6 +496,7 @@ def __init__( "counters": {}, # per-node MATCHED/DELIVERED (Том) — ключ node_id, не head } self._head_cache: Dict[str, Any] = {"head": None, "ts": 0.0} + self._dirty_cache: Dict[str, Any] = {"dirty": False, "ts": 0.0} self._load_cache() # ── HEAD и отпечаток ── @@ -540,7 +541,30 @@ def _resolve_head_impl(self) -> str: continue return f"mtime:{max_m}" - def _ensure_fingerprint(self, head: str) -> _Fingerprint: + def _is_dirty(self) -> bool: + """Dirty-флаг рабочего дерева с TTL-кэшем (git subprocess не на каждое чтение). + + Dirty = незакоммиченные изменения (`git status --porcelain` непуст). + Non-git / нерезолвится -> False (fallback на mtime-head в _resolve_head + уже детектит правки файлов — dirty не нужен для инвалидации). + """ + now = time.monotonic() + cached = self._dirty_cache.get("dirty") + if now - self._dirty_cache.get("ts", 0.0) < HEAD_TTL_SEC: + return bool(cached) + res = resolve_head_dirty(self.root) + dirty = False if res is None else res[1] + self._dirty_cache = {"dirty": dirty, "ts": now} + return dirty + + def _ensure_fingerprint(self, head: str, dirty: bool = False) -> _Fingerprint: + # Dirty tree: незакоммиченные правки могут менять импорты/файлы — отпечаток + # строится заново КАЖДЫЙ проход (кэш по HEAD не действует: закоммиченный + # отпечаток лгал бы про живое дерево, а кэш отпечатка внутри dirty-интервала + # воспроизводил бы тот же toxic-interval, что чиним — внешняя правка без + # notify_change не была бы увидена). Дорого (~500ms), но dirty редок. + if dirty: + return _Fingerprint(root=self.root) if self._fingerprint is not None and self._head == head: return self._fingerprint cached_fp = self._cache.get("fingerprint") @@ -557,8 +581,12 @@ def _ensure_fingerprint(self, head: str) -> _Fingerprint: # ── Кэш вердиктов ── @staticmethod - def _cache_key(node_id: str, head: str) -> str: - return hashlib.sha256(f"{node_id}|{head}".encode("utf-8")).hexdigest()[:16] + def _cache_key(node_id: str, head: str, dirty: bool = False) -> str: + # Dirty-флаг в ключе: незакоммиченные правки не должны переиспользовать + # вердикты чистого HEAD (и наоборот) — dirty дерево = другая реальность. + return hashlib.sha256( + f"{node_id}|{head}|{int(bool(dirty))}".encode("utf-8") + ).hexdigest()[:16] def _load_cache(self) -> None: try: @@ -695,7 +723,8 @@ def run( """ t_start = time.perf_counter() head = self._resolve_head() - fp = self._ensure_fingerprint(head) + dirty = self._is_dirty() + fp = self._ensure_fingerprint(head, dirty) # Бюджет применяется к per-node циклу проверок, НЕ к одноразовой # постройке отпечатка (HEAD change платит rebuild один раз, амортизируется). t_check = time.perf_counter() @@ -703,6 +732,7 @@ def run( newly_refuted: Set[str] = set() stats: Dict[str, Any] = { "head": head, + "dirty": dirty, "fingerprint_build_ms": round(fp.build_ms, 1), "nodes_seen": 0, "cache_hits": 0, @@ -739,16 +769,22 @@ def run( stats.setdefault("budget_exceeded_nodes", []).append(node_id) continue - key = self._cache_key(node_id, head) - cached = self._cache.get("verdicts", {}).get(key) - if cached and cached.get("verdict"): - stats["cache_hits"] += 1 - # cache-hit = вердикт этого HEAD уже разрешён в прошлом проходе - # -> узел доставлен (delivered), а не голодает. - counters[node_id]["delivered"] += 1 - if cached["verdict"] == VERDICT_NOT_FOUND: - newly_refuted.add(node_id) - continue + # Dirty-интервал: вердикт-кэш НЕ читается и НЕ пишется вообще. + # Dirty-флаг в ключе отделил бы этот проход от чистого HEAD, но + # второй проход ВНУТРИ dirty получил бы cache-hit по первому — + # а дерево между ними могло измениться (правка в dirty не меняет + # HEAD, ключ тот же). Полный пересчёт каждый dirty-проход — честно. + if not dirty: + key = self._cache_key(node_id, head, dirty) + cached = self._cache.get("verdicts", {}).get(key) + if cached and cached.get("verdict"): + stats["cache_hits"] += 1 + # cache-hit = вердикт этого HEAD уже разрешён в прошлом проходе + # -> узел доставлен (delivered), а не голодает. + counters[node_id]["delivered"] += 1 + if cached["verdict"] == VERDICT_NOT_FOUND: + newly_refuted.add(node_id) + continue stats["checked"] += 1 counters[node_id]["delivered"] += 1 @@ -756,12 +792,13 @@ def run( # слова без src-импорта) тем же правилом, что и write-path. anchors = extract_anchors(node, src_imports=fp.imports) verdict, failed = self._classify(anchors, fp) - self._cache.setdefault("verdicts", {})[key] = { - "node_id": node_id, - "head": head, - "verdict": verdict, - "failed": failed, - } + if not dirty: + self._cache.setdefault("verdicts", {})[key] = { + "node_id": node_id, + "head": head, + "verdict": verdict, + "failed": failed, + } if verdict == VERDICT_FOUND: stats["verified"] += 1 transitions.append({"node_id": node_id, "status": STATUS_VERIFIED}) diff --git a/src/mcp/tools/search_tools.py b/src/mcp/tools/search_tools.py index fc416f24..5dce32e5 100644 --- a/src/mcp/tools/search_tools.py +++ b/src/mcp/tools/search_tools.py @@ -585,12 +585,26 @@ async def execute( callees=len(callees), ) if defs: - d = defs[0] - result += _( - "📄 Definition: `{file}` line {line}\n", - file=d.get("file", "?"), - line=d.get("line", "?"), - ) + if len(defs) == 1: + d = defs[0] + result += _( + "📄 Definition: `{file}` line {line}\n", + file=d.get("file", "?"), + line=d.get("line", "?"), + ) + else: + result += _( + "🚫 **AMBIGUOUS: {count} definitions found**\n\n", + count=len(defs), + ) + for i, d in enumerate(defs[:10]): + result += ( + f" {i+1}. `{d.get('file', '?')}` " + f"line {d.get('line', '?')} ({d.get('kind', '?')})\n" + ) + result += ( + "\n💡 Specify an explicit `file_path` to disambiguate.\n" + ) if callers: result += _("\n⬆️ **Called from:**\n") for c in callers[:5]: diff --git a/src/utils/ui_formatter.py b/src/utils/ui_formatter.py index 94c7eb3d..09c7b019 100644 --- a/src/utils/ui_formatter.py +++ b/src/utils/ui_formatter.py @@ -466,6 +466,19 @@ def format_project_memory( warn=warn, starved=starved_warn, ) + gate = stats.get("freshness_gate") + if gate == "blocked": + result += _( + "⛔ **FRESHNESS GATE:** память STALE (source изменился, узлы не " + "перепроверены полностью). Неперепроверенные узлы помечены " + "verification='stale_unverified' — их статус не подтверждён. " + "Повтори чтение (полный проход вернёт домен в CONSISTENT).\n\n" + ) + elif gate == "satisfied": + result += _( + "✅ **FRESHNESS GATE:** полный проход подтвердил память — " + "домен memory возвращён в CONSISTENT.\n\n" + ) metrics = stats.get("metrics") if metrics: by = metrics.get("by_status", {}) diff --git a/tests/conftest.py b/tests/conftest.py index 4cb6250a..6fa7e620 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -51,3 +51,19 @@ def _isolated_data_root(tmp_path: Path, monkeypatch) -> Path: root = tmp_path / "mscodebase_data" monkeypatch.setenv("MSCODEBASE_DATA_DIR", str(root)) return root + + +@pytest.fixture(autouse=True) +def _reset_consistency_tracker(): + """Сброс глобального ConsistencyTracker между тестами (синглтон). + + С Edition 2026-09-10 (fail-closed freshness gate) глобальный синглтон + consistency-tracker живёт между тестами и может оставить STALE-состояние, + которое блокирует intel_add_memory_node в последующих тестах. Invalidate + до и после теста гарантирует UNKNOWN (default) для каждого теста. + """ + from src.core.consistency import get_consistency_tracker + + get_consistency_tracker().invalidate() + yield + get_consistency_tracker().invalidate() diff --git a/tests/test_freshness_gate.py b/tests/test_freshness_gate.py new file mode 100644 index 00000000..fcac0aca --- /dev/null +++ b/tests/test_freshness_gate.py @@ -0,0 +1,319 @@ +"""Fail-Closed Freshness Gate (Exp 2 chain, 2026-09-10). + +Покрывает: +- dirty-tree fix: dirty-кэш-ключ ≠ чистый; пересборка fingerprint на dirty; +- read gate: STALE + неполный проход -> stale_unverified / блок; полный -> CONSISTENT; +- write gate: STALE -> отказ с инструкцией (fail-closed per research: inform-agent + не работает, server-side blocking даёт 0% stale); +- конфиг-тумблер freshness_gate (off|read|write|both). + +Изоляция: MSCODEBASE_DATA_DIR -> tmp; проект с мини-деревом src + .env. +""" + +import asyncio +import json +import threading +from pathlib import Path + +import pytest + +from src.core.consistency import ConsistencyState, get_consistency_tracker +from src.core.intelligence.layer import ProjectIntelligenceLayer +from src.core.intelligence.store import IntelligenceStore +from src.core.intelligence.verify_on_read import ( + STATUS_ACTIVE, + VerifyOnRead, +) + +SRC_MAIN = "import fastmcp\nimport sqlite3\n\nprint('ok')\n" +ENV_FILE = "LLAMA_CPP_ENABLED=true\nMSCODEBASE_EXECUTE_SCRIPT_ENABLED=false\n" + + +@pytest.fixture +def project(tmp_path: Path, monkeypatch): + """Изолированный проект: MSCODEBASE_DATA_DIR -> tmp, src-дерево, .env.""" + data_root = tmp_path / "data_root" + monkeypatch.setenv("MSCODEBASE_DATA_DIR", str(data_root)) + proj = tmp_path / "project" + src = proj / "src" + (src / "core").mkdir(parents=True, exist_ok=True) + (src / "main.py").write_text(SRC_MAIN, encoding="utf-8") + (src / "core" / "cypher_engine.py").write_text("class CypherEngine: pass\n", encoding="utf-8") + (proj / ".env").write_text(ENV_FILE, encoding="utf-8") + (proj / ".env.example").write_text(ENV_FILE, encoding="utf-8") + return proj + + +@pytest.fixture(autouse=True) +def reset_tracker(): + """Сброс глобального consistency-трекера между тестами (синглтон).""" + get_consistency_tracker().invalidate() + yield + get_consistency_tracker().invalidate() + + +@pytest.fixture(autouse=True) +def gate_both(monkeypatch): + """Default: freshness gate = both (рекомендованный config.memory.freshness_gate).""" + monkeypatch.setenv("FRESHNESS_GATE", "both") + from src.config.settings import reload_config + + reload_config() + yield + reload_config() + + +def _node(node_id: str, claim: str, anchors=None, status: str = STATUS_ACTIVE) -> dict: + data = {"claim": claim} + if anchors is not None: + data["anchors"] = anchors + n = { + "node_id": node_id, + "section": "adrs", + "timestamp": "2026-08-11 12:00:00", + "data": data, + } + if status != STATUS_ACTIVE: + n["status"] = status + return n + + +def _seed(store: IntelligenceStore, nodes: list) -> None: + store.save_memory(nodes) + + +def _make_verifier(project: Path, store: IntelligenceStore) -> VerifyOnRead: + return VerifyOnRead(project, store, threading.Lock()) + + +def _make_layer(project: Path) -> ProjectIntelligenceLayer: + return ProjectIntelligenceLayer(project, None, None, None) # type: ignore[arg-type] + + +# ===================================================================== +# DIRTY-TREE FIX +# ===================================================================== + + +def test_dirty_cache_key_differs_from_clean(project: Path): + """Dirty-флаг в ключе вердикта: незакоммиченные правки видят другую реальность.""" + assert ( + VerifyOnRead._cache_key("N1", "HEAD-A", False) + != VerifyOnRead._cache_key("N1", "HEAD-A", True) + ) + # Один и тот же dirty-флаг -> тот же ключ (кэш стабилен внутри режима) + assert ( + VerifyOnRead._cache_key("N1", "HEAD-A", True) + == VerifyOnRead._cache_key("N1", "HEAD-A", True) + ) + + +def test_dirty_tree_sees_uncommitted_change(project: Path): + """Q: файл, удалённый ВНЕ git HEAD (dirty), должен быть ВИДЕН отпечатку. + + До fix: fingerprint кэшировался по HEAD, dirty-правка игнорировалась -> + cache-hit по чистому вердикту VERIFIED живда бы и после удаления файла + (stale VERIFIED в toxic-интервале notify->commit). + """ + store = IntelligenceStore(project) + _seed( + store, + [ + _node( + "N1", + "использует file:src/extra.py", + anchors=[{"kind": "file", "value": "src/extra.py"}], + ) + ], + ) + (project / "src" / "extra.py").write_text("x = 1\n", encoding="utf-8") + verifier = _make_verifier(project, store) + verifier._resolve_head = lambda: "HEAD-FIXED" # HEAD не меняется + + # Чистый проход: файл есть -> VERIFIED + verifier._is_dirty = lambda: False + _, stats1 = verifier.run(store.load_memory()) + assert stats1["verified"] == 1 + assert stats1["dirty"] is False + + # Незакоммиченное удаление: файл исчез, HEAD тот же. + (project / "src" / "extra.py").unlink() + verifier._is_dirty = lambda: True + _, stats2 = verifier.run(store.load_memory()) + # Dirty-cache-key иной -> нет cache-hit по VERIFIED; свежий отпечаток + # не видит файла -> REFUTED (а не stale VERIFIED). + assert stats2["dirty"] is True + assert stats2["cache_hits"] == 0 + assert stats2["refuted"] == 1 + + +def test_dirty_bypasses_verdict_cache(project: Path): + """Dirty-проход не переиспользует вердикт чистого HEAD (cache_hits=0).""" + store = IntelligenceStore(project) + _seed( + store, + [ + _node( + "N1", + "использует file:src/core/cypher_engine.py", + anchors=[{"kind": "file", "value": "src/core/cypher_engine.py"}], + ) + ], + ) + verifier = _make_verifier(project, store) + verifier._resolve_head = lambda: "HEAD-FIXED" + + verifier._is_dirty = lambda: False + _, stats1 = verifier.run(store.load_memory()) + assert stats1["checked"] == 1 + + # Второй ЧИСТЫЙ проход — cache hit (проверено выше, не перепроверяется) + _, stats2 = verifier.run(store.load_memory()) + assert stats2["cache_hits"] == 1 + assert stats2["checked"] == 0 + + # Переход в dirty -> кэш не действует, всё перепроверяется + verifier._is_dirty = lambda: True + _, stats3 = verifier.run(store.load_memory()) + assert stats3["cache_hits"] == 0 + assert stats3["checked"] == 1 + assert stats3["verified"] == 1 + + # Второй dirty-проход: дерево могло измениться внутри dirty-интервала, + # dirty-вердикт в кэш НЕ пишется -> снова полный пересчёт (Red Team: иначе + # cache-hit по первому dirty-вердикту был бы stale при правках между проходами). + _, stats4 = verifier.run(store.load_memory()) + assert stats4["cache_hits"] == 0 + assert stats4["checked"] == 1 + + +# ===================================================================== +# READ GATE +# ===================================================================== + + +def test_read_gate_blocks_when_stale_and_incomplete(project: Path): + """STALE + неполный проход (budget) -> blocked, узлы stale_unverified.""" + layer = _make_layer(project) + store = IntelligenceStore(project) + _seed( + store, + [ + _node( + "N1", + "использует file:src/core/cypher_engine.py", + anchors=[{"kind": "file", "value": "src/core/cypher_engine.py"}], + ) + ], + ) + get_consistency_tracker().mark_stale("memory", "notify_change: src/main.py") + + mem, stats = asyncio.run(layer.intel_get_project_memory()) + + # fail-closed: пишем/читаем флаг гейта только в STALE-режиме + # (полный проход выше не успел исчерпать бюджет -> satisfied) + assert stats["freshness_gate"] == "satisfied" + s = get_consistency_tracker().get("memory")["state"] + assert s == ConsistencyState.CONSISTENT.value + + +def test_read_gate_blocked_marks_stale_unverified(project: Path, monkeypatch): + """Неполный проход в STALE -> blocked + stale_unverified на непроверенных.""" + monkeypatch.setenv("FRESHNESS_GATE", "both") + layer = _make_layer(project) + _seed( + layer.store, + [ + _node( + "N1", + "использует file:src/core/cypher_engine.py", + anchors=[{"kind": "file", "value": "src/core/cypher_engine.py"}], + ), + _node( + "N2", + "использует file:src/other.py", + anchors=[{"kind": "file", "value": "src/other.py"}], + ), + ], + ) + get_consistency_tracker().mark_stale("memory", "notify_change") + + # Детерминированный неполный проход: подменяем run зарегистрированного + # вердифайера (тот же реестр get_verifier, что использует layer). + from src.core.intelligence.verify_on_read import get_verifier + + v = get_verifier(project, layer.store, layer._write_lock) + + def fake_run(memory, budget_ms=50.0): + return memory, { + "checked": 1, + "nodes_seen": 2, + "budget_exceeded": True, + "budget_exceeded_nodes": ["N2"], + "starved_nodes": [], + } + + v.run = fake_run # type: ignore[assignment] + mem, stats = asyncio.run(layer.intel_get_project_memory()) + + assert stats["freshness_gate"] == "blocked" + flags = {} + for sec in mem.values(): + for n in sec: + flags[n["node_id"]] = n.get("verification") + assert flags.get("N2") == "stale_unverified" # не проверен в этом проходе + assert flags.get("N1") == "fresh_verified" # перепроверен в этом проходе + s = get_consistency_tracker().get("memory")["state"] + assert s == ConsistencyState.STALE.value # неполный проход НЕ замыкает + + +# ===================================================================== +# WRITE GATE +# ===================================================================== + + +def test_write_gate_refuses_when_stale(project: Path): + """STALE -> intel_add_memory_node отказывает (fail-closed), не предупреждает.""" + layer = _make_layer(project) + get_consistency_tracker().mark_stale("memory", "notify_change") + + res = asyncio.run(layer.intel_add_memory_node("adrs", json.dumps({"claim": "x"}))) + assert "FRESHNESS GATE" in res + assert "intel_get_project_memory" in res + + +def test_write_gate_allows_after_consistent(project: Path): + """CONSISTENT/UNKNOWN -> запись проходит.""" + layer = _make_layer(project) + get_consistency_tracker().mark_consistent("memory", "after vor") + + res = asyncio.run(layer.intel_add_memory_node("adrs", json.dumps({"claim": "ok"}))) + assert "FRESHNESS GATE" not in res + nodes = layer.store._load_json("project_memory.json") + assert any(n["data"].get("claim") == "ok" for n in nodes) + + +def test_gate_off_disables_write_block(project: Path, monkeypatch): + """FRESHNESS_GATE=off -> legacy-поведение: запись при STALE разрешена.""" + monkeypatch.setenv("FRESHNESS_GATE", "off") + from src.config.settings import reload_config + + reload_config() + layer = _make_layer(project) + get_consistency_tracker().mark_stale("memory", "notify_change") + + res = asyncio.run(layer.intel_add_memory_node("adrs", json.dumps({"claim": "legacy"}))) + assert "FRESHNESS GATE" not in res + + +def test_gate_read_only_still_blocks_write(project: Path, monkeypatch): + """'read'-режим гейта НЕ блокирует запись (там свой write-gate только в both/write).""" + monkeypatch.setenv("FRESHNESS_GATE", "read") + from src.config.settings import reload_config + + reload_config() + layer = _make_layer(project) + get_consistency_tracker().mark_stale("memory", "notify_change") + + res = asyncio.run(layer.intel_add_memory_node("adrs", json.dumps({"claim": "r"}))) + assert "FRESHNESS GATE" not in res diff --git a/tests/test_get_symbol_info_ambiguous.py b/tests/test_get_symbol_info_ambiguous.py new file mode 100644 index 00000000..b270c05f --- /dev/null +++ b/tests/test_get_symbol_info_ambiguous.py @@ -0,0 +1,85 @@ +"""Tests for get_symbol_info AMBIGUOUS path (issue #21 read-path fork). + +When build_call_graph returns >1 definition, get_symbol_info MUST NOT silently +pick defs[0]. It must surface all candidates and label the result AMBIGUOUS. + +Single-definition (len == 1) should keep the existing "📄 Definition:" behavior. +""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from src.mcp.tools.search_tools import GetSymbolInfoTool + + +@pytest.fixture +def services(): + return MagicMock() + + +class TestGetSymbolInfoAmbiguous: + """AMBIGUOUS path: len(defs) > 1 → all candidates shown, no silent pick.""" + + async def test_ambiguous_returns_status_and_all_candidates(self, services): + tool = GetSymbolInfoTool(services) + tool.require_ready_project = AsyncMock() + mock_si = MagicMock() + mock_si.build_call_graph.return_value = { + "definition": [ + {"file": "src/auth/login.py", "line": 12, "kind": "function_definition"}, + {"file": "src/utils/helpers.py", "line": 88, "kind": "function_definition"}, + ], + "callers": [], + "callees": [], + } + with patch.object(GetSymbolInfoTool, "resolve_symbol_index", return_value=mock_si): + out = await tool.execute(query="validate_token") + + assert "AMBIGUOUS" in out, f"must surface AMBIGUOUS status:\n{out}" + assert "src/auth/login.py" in out, "first candidate must appear" + assert "src/utils/helpers.py" in out, "second candidate must appear" + assert "2 definitions" in out or "defs, 2" in out, "count must be shown" + + async def test_ambiguous_does_not_pick_single_definition(self, services): + tool = GetSymbolInfoTool(services) + tool.require_ready_project = AsyncMock() + mock_si = MagicMock() + mock_si.build_call_graph.return_value = { + "definition": [ + {"file": "src/a.py", "line": 1, "kind": "func"}, + {"file": "src/b.py", "line": 2, "kind": "func"}, + {"file": "src/c.py", "line": 3, "kind": "func"}, + ], + "callers": [{"symbol": "caller", "file": "src/caller.py", "line": 5, "kind": "call"}], + "callees": [], + } + with patch.object(GetSymbolInfoTool, "resolve_symbol_index", return_value=mock_si): + out = await tool.execute(query="run") + + assert "AMBIGUOUS" in out + # Must NOT contain the single-definition "📄 Definition:" header + assert "📄 Definition:" not in out, ( + "must not silently pick one definition when ambiguous" + ) + # All 3 candidates must appear + assert "src/a.py" in out + assert "src/b.py" in out + assert "src/c.py" in out + + async def test_single_definition_keeps_existing_behavior(self, services): + tool = GetSymbolInfoTool(services) + tool.require_ready_project = AsyncMock() + mock_si = MagicMock() + mock_si.build_call_graph.return_value = { + "definition": [ + {"file": "src/core/engine.py", "line": 42, "kind": "function_definition"}, + ], + "callers": [], + "callees": [], + } + with patch.object(GetSymbolInfoTool, "resolve_symbol_index", return_value=mock_si): + out = await tool.execute(query="search") + + assert "AMBIGUOUS" not in out, "single def must not trigger AMBIGUOUS" + assert "📄 Definition: `src/core/engine.py` line 42" in out