Skip to content
Open
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
7 changes: 7 additions & 0 deletions AGENT_DIARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -1941,3 +1941,10 @@ BREACH: MSC-P-19 | date: 2026-10-03 | rule: §12 | guard_at_time: no | detected_
**3. Код установщика в диффах есть.** `gh pr diff 120 | grep auto_yes` → удалённая строка `range(0 if auto_yes …)`, добавленная `range(1 if auto_yes …)` и тест `test_auto_yes_runs_each_step_once`. `gh pr diff 121 | grep "def _"` → `models_enabled`, `_doctor_report`, `_doctor_cli`, `_clean_targets`, `_clean_cli` (имена не `cmd_*`; поиск по `cmd_clean|cmd_doctor` пуст по этой причине).
**4. Гипотеза «clean-state до фикса валидировал пустую установку» — опровергнута.** `scripts/verify_clean_state.sh` вообще не вызывает `install.py`: он сам создаёт venv и делает `pip install -e .`; `grep "install.py --yes"` по `.github/` и `scripts/` пуст. Значит баг `--yes` с v3.0 не влиял ни на один прогон CI — ровно поэтому его никто и не поймал; поймал только ручной прогон по README.
**Урок:** «CI зелёный» и «инструкция работает» — разные утверждения. Пока установщик не запускали как пользователь (по README, в пустом каталоге), его неинтерактивная ветка была мёртвой, а все гейты оставались зелёными.

## [2026-11-13] 5 красных в slow-зоне: тесты читали не то хранилище (fix/freshness-slow-tests)
**Симптом:** на `main` (`a6524d6` и `801621d`) `pytest tests/test_freshness_checker.py -m "not benchmark"` → 5 failed / 2 passed; в дефолтном гейте файл не виден, потому что помечен `pytestmark = pytest.mark.slow`.
**Корень:** тесты читали `indexer.table` — таблицу LanceDB, — а дефолтное хранилище теперь SQLite (`src/core/indexing/indexer.py:146-154`, `src/config/settings.py::SearchConfig.sqlite_chunks` = `true`). `_index_single_file()` писал в `SQLiteChunkStore`, LanceDB оставался пустым → `set()` в `assert "b.py" in set(df["file_path"])` и `0 == 1` в проверках схемы. Это не регресс Цели 1/2: те же 5 падали на до-мержевом `main`.
**Fix (тесты, не код):** чтение через `_file_rows(indexer, columns)` — берёт слой, который реально использует индексатор, и агрегирует чанки SQLite по `file_path`; добавлен `test_store_is_the_default_one`, который пиннит дефолт (`sqlite`), чтобы дрейф был виден. `slow`-маркер снят: файл занимает 4.2 s и обязан быть в гейте — иначе эта поломка снова спрячется.
**Проверка:** до фикса 5 failed / 2 passed; после — 8 passed. Мутация: `verify_index_freshness()` принудительно `return 0` → 4 failed / 4 passed (код восстановлен, тесты краснеют как надо). `ruff check src/ tests/` — чисто.
**Урок:** «slow» — это не «не важно», а «не проверяется». Если тест читает конкретный бэкенд, он обязан падать громко при смене дефолта, а не жить в исключённой зоне.
89 changes: 61 additions & 28 deletions tests/test_freshness_checker.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,28 +7,30 @@
- Изменённый файл переиндексируется на лету, полный reindex не вызывается.
- Проверка схемы: новые колонки file_mtime_ns / file_size записываются.

Изоляция: tmp-проект + tmp LanceDB, мок embedder (как test_lsp_vfs_indexing).
Изоляция: tmp-проект + tmp-хранилище, мок embedder (как test_lsp_vfs_indexing).

Хранилище: по умолчанию движок пишет в SQLite (`MSCODEBASE_SQLITE_CHUNKS=true`,
`src/core/indexing/indexer.py:146-154`), LanceDB — только при выключенном флаге.
Тесты читают тот слой, который реально используется, через `_file_rows()`; раньше
они всегда читали `indexer.table` (LanceDB) и потому падали на дефолтной конфигурации.
"""

from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from unittest.mock import MagicMock

import pytest

from src.core.indexing.file_guard import FileGuard
from src.core.indexing.indexer import Indexer
from src.sources.local_fs import LocalFsSource

pytestmark = pytest.mark.slow


def _make_embedder():
"""Mock embedder c фиксированным embedding_dim.

embedding_dim обязателен: без него ``self.embedder.embedding_dim or 768``
(db_writer.py:59) получает truthy-MagicMock и обрезает вектор до нулевого.
"""
from unittest.mock import MagicMock

embedder = MagicMock()
embedder.embedding_dim = 1024
embedder.embed_batch.return_value = [[0.1] * 1024]
Expand Down Expand Up @@ -57,26 +59,56 @@ def _make_indexer(tmp_path: Path, mock_embedder, interval_sec: float = 0.0):
return indexer


def _file_rows(indexer, columns):
"""Одна строка на ФАЙЛ из хранилища, которое реально использует индексатор.

SQLite-стор отдаёт чанки (несколько строк на файл), поэтому агрегируем по
`file_path`; для LanceDB поведение прежнее — одна строка на чанк, но тестовые
файлы дают ровно один чанк.
"""
if indexer._chunk_store is not None:
by_file: dict[str, dict] = {}
for rec in indexer._chunk_store.all_records():
by_file.setdefault(rec["file_path"], rec)
return [{name: rec.get(name) for name in columns} for rec in by_file.values()]
return indexer.table.to_lance().to_pandas(columns=columns).to_dict("records")


def _store_name(indexer) -> str:
return "sqlite" if indexer._chunk_store is not None else "lancedb"


class TestFreshnessChecker:
def test_store_is_the_default_one(self, tmp_path):
"""Дефолт обязан быть SQLite: тесты ниже проверяют именно его."""
indexer = _make_indexer(tmp_path, _make_embedder())
assert _store_name(indexer) == "sqlite", (
"MSCODEBASE_SQLITE_CHUNKS должен быть true по умолчанию "
"(src/config/settings.py::SearchConfig.sqlite_chunks)"
)

def test_schema_has_mtime_size(self, tmp_path):
"""Новые колонки file_mtime_ns / file_size присутствуют в схеме."""
mock_embedder = _make_embedder()
indexer = _make_indexer(tmp_path, mock_embedder)
field_names = {f.name for f in indexer.table.schema}
assert "file_mtime_ns" in field_names
assert "file_size" in field_names
"""file_mtime_ns / file_size есть в обоих хранилищах."""
indexer = _make_indexer(tmp_path, _make_embedder())
src_file = tmp_path / "a.py"
src_file.write_text("def a(): return 1\n", encoding="utf-8")
assert indexer._index_single_file(src_file, "a.py") is True

rows = _file_rows(indexer, ["file_path", "file_mtime_ns", "file_size"])
assert rows and rows[0]["file_mtime_ns"] == int(src_file.stat().st_mtime_ns)
assert rows[0]["file_size"] == int(src_file.stat().st_size)

def test_data_records_contain_mtime_size(self, tmp_path):
"""После индексации файла в чанках проставлены mtime_ns и size."""
"""После индексации файла в записях проставлены mtime_ns и size."""
mock_embedder = _make_embedder()
indexer = _make_indexer(tmp_path, mock_embedder)
src_file = tmp_path / "a.py"
src_file.write_text("def a(): return 1\n", encoding="utf-8")

assert indexer._index_single_file(src_file, "a.py") is True

df = indexer.table.to_lance().to_pandas(columns=["file_path", "file_mtime_ns", "file_size"])
row = df[df["file_path"] == "a.py"].iloc[0]
rows = _file_rows(indexer, ["file_path", "file_mtime_ns", "file_size"])
row = next(r for r in rows if r["file_path"] == "a.py")
assert row["file_mtime_ns"] == int(src_file.stat().st_mtime_ns)
assert row["file_size"] == int(src_file.stat().st_size)

Expand All @@ -94,8 +126,8 @@ def test_new_file_detected_by_verify(self, tmp_path):
reindexed = indexer.verify_index_freshness(tmp_path)
assert reindexed >= 1

df = indexer.table.to_lance().to_pandas(columns=["file_path"])
assert "b.py" in set(df["file_path"])
paths = {r["file_path"] for r in _file_rows(indexer, ["file_path"])}
assert "b.py" in paths

def test_unchanged_file_not_reindexed(self, tmp_path):
"""Stat-first: неизменённый файл не модифицируется и не вызывает hash-чтение."""
Expand All @@ -112,8 +144,9 @@ def test_unchanged_file_not_reindexed(self, tmp_path):
assert reindexed == 0

# Содержимое одинаково — никаких новых записей
df = indexer.table.to_lance().to_pandas(columns=["file_path", "indexed_at", "file_hash"])
assert len(df) == len(df.drop_duplicates(subset=["file_path", "file_hash"]))
rows = _file_rows(indexer, ["file_path", "file_hash"])
unique = {(r["file_path"], r["file_hash"]) for r in rows}
assert len(rows) == len(unique)

def test_changed_file_hot_reloaded(self, tmp_path):
"""Изменённый файл переиндексируется на лету, без полного reindex."""
Expand All @@ -123,15 +156,15 @@ def test_changed_file_hot_reloaded(self, tmp_path):
src_file.write_text("def a(): return 1\n", encoding="utf-8")
assert indexer._index_single_file(src_file, "a.py") is True

old_hash = indexer.table.to_lance().to_pandas(columns=["file_hash"]).iloc[0]["file_hash"]
old_hash = _file_rows(indexer, ["file_hash"])[0]["file_hash"]

# Правим содержимое файла (новое содержимое)
src_file.write_text("def a(): return 42\n", encoding="utf-8")

reindexed = indexer.verify_index_freshness(tmp_path)
assert reindexed >= 1 # файл переиндексирован

new_hash = indexer.table.to_lance().to_pandas(columns=["file_hash"]).iloc[0]["file_hash"]
new_hash = _file_rows(indexer, ["file_hash"])[0]["file_hash"]
assert new_hash != old_hash # контент действительно обновился

def test_hot_reload_is_full_pipeline_not_delete_all(self, tmp_path):
Expand All @@ -148,11 +181,11 @@ def test_hot_reload_is_full_pipeline_not_delete_all(self, tmp_path):
reindexed = indexer.verify_index_freshness(tmp_path)
assert reindexed >= 1

df = indexer.table.to_lance().to_pandas(columns=["file_path", "file_hash"])
hash_b = df[df["file_path"] == "b.py"].iloc[0]["file_hash"]
assert len(df) == 2 # оба файла на месте, дублей нет
rows = _file_rows(indexer, ["file_path", "file_hash"])
by_path = {r["file_path"]: r["file_hash"] for r in rows}
assert set(by_path) == {"a.py", "b.py"} # оба файла на месте, дублей нет
# b.py'шный hash не изменился (не был переиндексирован)
assert hash_b == indexer._calculate_file_hash(tmp_path / "b.py")
assert by_path["b.py"] == indexer._calculate_file_hash(tmp_path / "b.py")

def test_concurrent_verify_parallel(self, tmp_path):
"""Стресс: N=8 параллельных verify() → корректность результата.
Expand Down Expand Up @@ -185,7 +218,7 @@ def _run_verify(_):
# (debounce=0: хотя бы один поток реально переиндексировал)
assert any(r >= 1 for r in results)

df = indexer.table.to_lance().to_pandas(columns=["file_path", "file_hash"])
a_rows = df[df["file_path"] == "a.py"]
rows = _file_rows(indexer, ["file_path", "file_hash"])
a_rows = [r for r in rows if r["file_path"] == "a.py"]
assert len(a_rows) == 1 # дублей после гонки нет
assert a_rows.iloc[0]["file_hash"] == indexer._calculate_file_hash(tmp_path / "a.py")
assert a_rows[0]["file_hash"] == indexer._calculate_file_hash(tmp_path / "a.py")
Loading