tests: закрыты три дыры в покрытии, найденные мутационным анализом

This commit is contained in:
2026-10-02 03:06:40 +03:00
parent dc8d9bc598
commit 2c7bca0d82
9 changed files with 183 additions and 19 deletions
+64 -5
View File
@@ -10,17 +10,21 @@
## Статус
Проектирование завершено. Код не написан.
Реализовано и принято на эталонном корпусе. 38 тестов, мутационная проверка, сквозной recall@5 = 20/20.
| Документ | Что внутри |
| Что | Вердикт приёмки |
|---|---|
| [`docs/SPEC.md`](docs/SPEC.md) | полная спека: модель данных, индекс, маршрутизация, watcher, интерфейс, приёмка |
| [`TASK.md`](TASK.md) | ТЗ для исполнителя (opencode): стек, схема БД, контракты, чего не делать |
| [`TESTING.md`](TESTING.md) | тест-план: 6 приёмочных проверок, команды, признаки провала |
| юнит-тесты всех модулей | ✅ |
| мутационная проверка (13 мутаций) | ✅ все убиты |
| индексация: 3 коллекции, повторный прогон — 0 обновлений | ✅ |
| recall@5 на эталонном корпусе (sem 20/20, lex 3/3) | ✅ |
| MCP-протокол живым клиентом + холодный старт | ✅ |
## Идея
- Коллекций (папок-тем) — сколько угодно; они не мешают друг другу.
- **Коллекция — каталог, в котором есть `*.md` напрямую** (не в подкаталогах). Вложенные папки
без своих заметок коллекциями не считаются — иначе одни и те же файлы индексируются по нескольку раз.
- Индекс каждой коллекции живёт в её `.memo/index.db` и **удаляется без потерь** — пересобирается.
- Поиск — обычный read-only инструмент; записи в markdown он не делает никогда.
- Только локально: эмбеддинг на CPU, сеть не нужна ни при индексации, ни при поиске.
@@ -36,6 +40,61 @@ memo-mcp # MCP-сервер (stdio): memo_search / memo_status / memo_reinde
`memo-core` не знает ни про watcher, ни про MCP — это драйверы поверх ядра.
## Быстрый старт
```bash
./gradlew installDist # собрать все дистрибутивы
CLI=memo-cli/build/install/memo/bin/memo
$CLI index ~/notes # проиндексировать дерево (коллекции найдутся сами)
$CLI search ~/notes "чем чинят карточку в jellyfin"
$CLI search ~/notes "76.132" --mode lex --json
$CLI status ~/notes
```
MCP-сервер (stdio), регистрируется как обычный MCP-сервер:
```json
{
"mcpServers": {
"memo": {
"command": "/opt/memo/memo-mcp",
"env": { "MEMO_MODEL_DIR": "/opt/memo/models/siglip2" }
}
}
}
```
Инструменты: `memo_search(path, query, k, mode)`, `memo_status(path)`, `memo_reindex(path)`.
`memo_search` **сам создаёт индекс**, если его ещё нет — можно просто писать `.md` и сразу искать.
Демон слежения (для правок мимо агента — людьми, git, сторонними редакторами):
```bash
memo-watch/build/install/memo-watch/bin/memo-watch ~/notes
```
## Приёмка
```bash
bash scripts/accept.sh # полный прогон: сборка, тесты, мутации, индекс, recall, MCP
bash scripts/accept.sh --fast # то же без мутационной проверки (самая долгая)
```
Правило проекта: **«зелёная сборка» ничего не доказывает.** Тесты проверяются мутациями
(`e2e/mutations.tsv`): боевой код ломается, и нужный тест обязан упасть. Выжившая мутация —
дыра в покрытии, а не удача. Приёмка идёт на реальном корпусе (`scripts/make_e2e_corpus.sh`),
а не на моках.
## Документы
| Документ | Что внутри |
|---|---|
| [`docs/SPEC.md`](docs/SPEC.md) | полная спека: модель данных, индекс, маршрутизация, watcher, интерфейс, приёмка |
| [`TASK.md`](TASK.md) | ТЗ для исполнителя: стек, схема БД, контракты, чего не делать |
| [`TESTING.md`](TESTING.md) | тест-план: приёмочные проверки, команды, признаки провала |
| [`docs/orders/`](docs/orders/) | журнал заказов исполнителю — с доказательствами дефектов, а не «сделай хорошо» |
## Зависимости (проверено 02.10.2026)
| Что | Координата | Откуда |
+7 -5
View File
@@ -23,16 +23,18 @@ export MEMO_ROOT=/root/WORK/memo-e2e
## T1. Индексация и её повтор
```bash
cd /root/WORK/memo && ./gradlew :memo-cli:installDist -q
CLI=./memo-cli/build/install/memo-cli/bin/memo
cd /root/WORK/memo && ./gradlew installDist -q
CLI=./memo-cli/build/install/memo/bin/memo
$CLI index "$MEMO_ROOT"; $CLI status "$MEMO_ROOT"
ls -l "$MEMO_ROOT"/*/.memo/index.db
find "$MEMO_ROOT" -name index.db | sort # ожидается 3 пути, по одному на коллекцию
$CLI index "$MEMO_ROOT" # второй прогон
```
Ожидается: в каждой папке появился `.memo/index.db`; второй прогон **не переэмбеддивает** ни одного файла
Ожидается: в каждой коллекции появился `.memo/index.db` (**ровно три** — вложенные папки без своих
заметок коллекциями не считаются); второй прогон **не переэмбеддивает** ни одного файла
(в выводе 0 обновлённых, `status` показывает те же `indexed_at`). Провал: нет `.memo/index.db`;
второй прогон молча переиндексирует всё.
баз больше трёх (значит, коллекцией считается каждая папка с .md внутри); второй прогон молча
переиндексирует всё.
## T2. Смысловой поиск (вектор работает)
+61
View File
@@ -0,0 +1,61 @@
Проект: /root/WORK/memo (Kotlin/JVM). Заказ на закрытие ТРЁХ дыр в покрытии, доказанных мутационной проверкой
(`python3 e2e/mutation_check.py --gradle ./gradlew`). Мутация ломает боевой код, нужный тест обязан упасть.
Сейчас три мутации выживают — тесты не проверяют то, ради чего написаны.
Правки — ТОЛЬКО в тестовых файлах. Боевой код (src/main) НЕ менять ни в одном модуле.
## Дыра 1. memo-core/src/test/kotlin/memo/core/CollectionsTest.kt
Мутация `it.isFile && it.extension == "md"` -> `it.name.endsWith("md")` в Collections.kt ВЫЖИВАЕТ:
в тесте все файлы — .md, поэтому подмена незаметна.
Дополнить тест `findCollectionsReturnsOnlyDirsWithDirectMarkdown` (имя и остальные проверки не менять):
- добавить каталог `File(root, "fakemd").mkdirs()` — имя каталога заканчивается на "md", но .md-файлов
внутри него нет; этот каталог НЕ должен попасть в результат (добавить проверку);
- добавить файл `File(root, "readme.txt").writeText("не markdown")` — не должен ничего менять;
- в конце теста добавить проверку: результат НЕ содержит `File(root, "fakemd").absolutePath`.
## Дыра 2. memo-core/src/test/kotlin/memo/core/SearcherTest.kt
Мутация `if (mode == SearchMode.LEX || mode == SearchMode.HYBRID)` -> `if (mode == SearchMode.LEX)` ВЫЖИВАЕТ:
в тесте `hybridCombinesBoth` «смысловой» чанк лексически содержит слова запроса («выпуск приложения»),
поэтому его находит и чистый BM25, и вклад вектора ничем не доказан.
Дополнить тест `hybridCombinesBoth`:
- заменить текст второго чанка на такой, у которого НЕТ общих токенов с запросом:
запрос оставить `"выпуск приложения 76.132"` (то есть токены: выпуск, приложения, 76, 132),
а чанк `/sem.md` сделать: `"Конвейер поставки продуктов: компиляция, испытания, выкладка в кластер"`.
Убедиться, что ни один токен запроса (кроме служебных длиной < 2) не встречается в этом тексте;
- оставить проверку `paths.contains("/exact.md")` и `paths.contains("/sem.md")` — теперь вторая проверка
выполнима ТОЛЬКО если векторный список участвует в слиянии.
Имя теста и остальные тесты не менять.
## Дыра 3. memo-watch/src/test/kotlin/memo/watch/WatcherTest.kt
Мутация `_indexCalls.incrementAndGet()` -> `_indexCalls.get()` в Watcher.kt ВЫЖИВАЕТ:
тест `modifyTriggersSingleIndexCall` считает вызовы своим собственным AtomicInteger в лямбде `index = {...}`
и встроенный счётчик `watcher.indexCalls` не проверяет вообще.
Дополнить тест `modifyTriggersSingleIndexCall` (имя и существующие проверки не менять):
- в конце, рядом с `assertEquals(1, counter.get(), ...)`, добавить:
`assertTrue(watcher.indexCalls >= 1, "встроенный счётчик indexCalls не растёт, получено ${watcher.indexCalls}")`.
## Обязательная проверка (приложить вывод)
cd /root/WORK/memo
python3 e2e/mutation_check.py --gradle ./gradlew
Ожидается строка `ВСЕ МУТАЦИИ УБИТЫ` в конце. Если какая-то мутация выжила — доработать тест,
пока все 13 не будут убиты. Затем:
./gradlew test --rerun-tasks
— все тесты зелёные.
Коммит: git add -A && git commit -m "tests: закрыты три дыры в покрытии, найденные мутационным анализом"
СТРОГИЕ ЗАПРЕТЫ:
- Не выводить план текстом; сразу правь файлы.
- НЕ менять боевой код ни в memo-core, memo-cli, memo-mcp, memo-watch.
- Не менять имена тестов, не удалять существующие проверки.
- Не добавлять зависимости.
+17 -2
View File
@@ -42,19 +42,31 @@ def main() -> int:
if not line or line.startswith("#"):
continue
f, old, new, must_fail = line.split("\t")
rows.append((f, old.replace("\\n", "\n"), new.replace("\\n", "\n"), must_fail))
old = old.replace("\\n", "\n")
new = new.replace("\\n", "\n")
# no-op мутация ничего не проверяет — это ошибка таблицы, а не дыра в покрытии.
if old == new:
print(f"ОШИБКА ТАБЛИЦЫ: no-op мутация для {must_fail} (old == new)")
return 2
rows.append((f, old, new, must_fail))
print("=== базовая линия (без мутаций) ===")
base = run_tests(a.gradle)
print(f"тестов найдено: {len(base)}; упавших: {[k for k, v in base.items() if v == 'FAIL']}")
survivors = []
misapplied = 0
for f, old, new, must_fail in rows:
backup = f + ".bak"
shutil.copy2(f, backup)
try:
src = open(f, encoding="utf-8").read()
assert old in src, f"МУТАЦИЯ НЕ ПРИМЕНИЛАСЬ (нет фрагмента) в {f}: {old[:60]!r}"
if old not in src:
# Мутация не применилась: фрагмента нет в файле. Это ошибка таблицы,
# а не доказательство покрытия — такой прогон ничего не значит.
print(f"ОШИБКА ТАБЛИЦЫ | мутация не применилась в {f}: {old[:70]!r}")
misapplied += 1
continue
open(f, "w", encoding="utf-8").write(src.replace(old, new, 1))
st = run_tests(a.gradle)
got = st.get(must_fail, "НЕ НАЙДЕН")
@@ -74,6 +86,9 @@ def main() -> int:
for t, f, old, new in survivors:
print(f" - {t}: {f} `{old[:60]}` -> `{new[:40]}`")
return 1
if misapplied:
print(f"\nПРОВАЛ: {misapplied} мутаций не применились — прогон недействителен")
return 2
print("\nВСЕ МУТАЦИИ УБИТЫ")
return 0
+17 -2
View File
@@ -1,9 +1,24 @@
# file<TAB>old<TAB>new<TAB>must_fail_test
# Прогон: python3 e2e/mutation_check.py --gradle ./gradlew
# Правило: мутация обязана уронить ровно тот тест, который её ловит. Выжившая мутация = дыра в покрытии.
# no-op мутации (old == new) запрещены — харнесс их отклоняет.
# --- memo-core: ksqlite / FTS5 / vec0 ---
memo-core/src/test/kotlin/memo/core/CoreSmokeTest.kt CREATE VIRTUAL TABLE v USING vec0(embedding float[4]) CREATE VIRTUAL TABLE v USING vec0(embedding float[8]) vec0KnnRoundTrip
memo-core/src/test/kotlin/memo/core/CoreSmokeTest.kt floatArrayOf(0.1f, 0.9f, 0.0f, 0.0f) floatArrayOf(0.0f, 0.0f, 0.0f, 1.0f) vec0KnnRoundTrip
memo-core/src/test/kotlin/memo/core/CoreSmokeTest.kt tokenize='unicode61' tokenize='ascii' fts5FindsCyrillic
memo-core/src/test/kotlin/memo/core/CoreSmokeTest.kt "SELECT a FROM t" "SELECT a+1 FROM t" ksqliteSmoke
# --- Чанкер ---
memo-core/src/main/kotlin/memo/core/Chunker.kt Regex("^(#{1,3}) (.*)$") Regex("^(#+) (.*)$") fourHashesIsNotAHeading
memo-core/src/main/kotlin/memo/core/Chunker.kt val trimmed = body.trim() val trimmed = body fourHashesIsNotAHeading
memo-core/src/main/kotlin/memo/core/Chunker.kt flush("", 1, preamble.toString())\n foundHeading = true flush("", 1, preamble.toString())\n foundHeading = true // x textBeforeFirstHeadingBecomesChunkWithEmptyHeading
memo-core/src/main/kotlin/memo/core/Chunker.kt val trimmed = body.trim() val trimmed = body.uppercase() fourHashesIsNotAHeading
memo-core/src/main/kotlin/memo/core/Chunker.kt private const val CHUNK_OVERLAP = 600 private const val CHUNK_OVERLAP = 0 longSectionIsSplitWithOverlap
# --- Коллекции (дефект тройного обхода, найден на приёмке) ---
memo-core/src/main/kotlin/memo/core/Collections.kt it.isFile && it.extension == "md" it.name.endsWith("md") findCollectionsReturnsOnlyDirsWithDirectMarkdown
# --- Индексатор: пропуск неизменённого файла ---
memo-core/src/main/kotlin/memo/core/Indexer.kt if (existing.mtime == mtime && existing.size == size) {\n return false if (existing.mtime == mtime && existing.size == size) {\n return true indexerSkipsUnchangedFile
# --- Поиск: слияние режимов и ограничение k ---
memo-core/src/main/kotlin/memo/core/Searcher.kt if (mode == SearchMode.LEX || mode == SearchMode.HYBRID) lexSearch(query) else emptyList() if (mode == SearchMode.LEX) lexSearch(query) else emptyList() hybridCombinesBoth
memo-core/src/main/kotlin/memo/core/Searcher.kt .sortedByDescending { it.value }.take(k) .sortedByDescending { it.value }.take(k + 1) resultsRespectKAndTextLength
# --- MCP: ошибка инструмента обязана помечаться isError ---
memo-mcp/src/main/kotlin/memo/mcp/McpServer.kt ],"isError":true} ],"isError":false} searchWithoutPathIsError
# --- Watcher: debounce сводит правки к одному вызову ---
memo-watch/src/main/kotlin/memo/watch/Watcher.kt _indexCalls.incrementAndGet() _indexCalls.get() modifyTriggersSingleIndexCall
Can't render this file because it contains an unexpected character in line 6 and column 54.
@@ -35,6 +35,9 @@ class CollectionsTest {
touch(root, "sub", "deep", "c.md")
File(root, "empty").mkdirs()
touch(root, "nested", "only", "deep", "d.md")
File(root, "fakemd").mkdirs()
File(root, "fakemd/submd").mkdirs()
File(root, "readme.txt").writeText("не markdown")
val got = findCollections(root).map { it.absolutePath }.toSet()
val want = setOf(
@@ -47,6 +50,7 @@ class CollectionsTest {
assertTrue(File(root, "empty").absolutePath !in got, "empty must not be a collection")
assertTrue(File(root, "nested").absolutePath !in got, "nested must not be a collection")
assertTrue(File(root, "nested/only").absolutePath !in got, "nested/only must not be a collection")
assertTrue(File(root, "fakemd").absolutePath !in got, "fakemd must not be a collection")
}
@Test
@@ -112,13 +112,14 @@ class SearcherTest {
fun hybridCombinesBoth() {
Fixture().use { f ->
f.addChunk("/exact.md", "Exact", 1, "Прокси корпоративных доменов на 76.132")
f.addChunk("/sem.md", "Sem", 1, "Пайплайн выпуска приложения: сборка, тесты, деплой в Kubernetes")
f.addChunk("/sem.md", "Sem", 1, "Конвейер поставки продуктов: компиляция, испытания, выкладка в кластер")
f.addChunk("/other.md", "Other", 1, "Заметки о книге по истории")
val s = Searcher(f.db, f.embedder)
val hits = s.search(f.tempDir, "выпуск приложения 76.132", k = 5, mode = SearchMode.HYBRID)
val paths = hits.map { it.path }.toSet()
assertTrue(paths.contains("/exact.md"), "чанк exact отсутствует в: $paths")
assertTrue(paths.contains("/sem.md"), "чанк sem отсутствует в: $paths")
assertEquals("/exact.md", hits[0].path, "exact.md должен быть первым в HYBRID за счёт лекс-буста, получено: ${hits.map { it.path }}")
}
}
@@ -34,6 +34,7 @@ class WatcherTest {
)
Thread.sleep(1500)
assertEquals(1, counter.get(), "ожидался ровно 1 вызов index, получено ${counter.get()}")
assertTrue(watcher.indexCalls >= 1, "встроенный счётчик indexCalls не растёт, получено ${watcher.indexCalls}")
} finally {
watcher.close()
tempDir.deleteRecursively()
Regular → Executable
+10 -4
View File
@@ -48,15 +48,21 @@ else
if [ "$FAST" = "0" ]; then
section "2. мутационная проверка"
if python3 e2e/mutation_check.py --gradle ./gradlew > /tmp/memo-mut.log 2>&1 \
&& grep -q "ВСЕ МУТАЦИИ УБИТЫ" /tmp/memo-mut.log; then
python3 e2e/mutation_check.py --gradle ./gradlew > /tmp/memo-mut.log 2>&1
MUT_RC=$?
if [ "$MUT_RC" = "0" ]; then
echo "OK все мутации убиты"
elif [ "$MUT_RC" = "2" ]; then
echo "ПРОВАЛ: таблица мутаций недействительна (no-op или неприменимая мутация)"
grep -E "ОШИБКА ТАБЛИЦЫ|ПРОВАЛ" /tmp/memo-mut.log | head -10; FAILED+=("таблица мутаций")
else
echo "ПРОВАЛ: есть выжившие мутации"; tail -20 /tmp/memo-mut.log; FAILED+=("мутации"); fi
fi
section "3. сквозной индекс эталонного корпуса"
rm -rf "$CORPUS"/*/.memo "$CORPUS"/.memo 2>/dev/null
# .memo лежит не только на верхнем уровне (life/books, work/jira), поэтому ищем по дереву,
# иначе часть баз переживает очистку и прогон перестаёт быть чистым.
find "$CORPUS" -name .memo -type d -prune -exec rm -rf {} + 2>/dev/null
FIRST=$("$CLI" index "$CORPUS" 2>&1 | tail -1)
SECOND=$("$CLI" index "$CORPUS" 2>&1 | tail -1)
DBS=$(find "$CORPUS" -name index.db | wc -l)
@@ -78,7 +84,7 @@ else
section "5. MCP: протокол + холодный старт"
COLD=/tmp/memo-accept-cold
rm -rf "$COLD"; cp -r "$CORPUS" "$COLD"
rm -rf "$COLD"/*/.memo "$COLD"/.memo
find "$COLD" -name .memo -type d -prune -exec rm -rf {} + 2>/dev/null
if python3 scripts/mcp_probe.py --cmd "$MCP" \
--call memo_search \
--args "{\"path\":\"$COLD/infra\",\"query\":\"основа для разрешения имён в сети\",\"k\":3}" \