tests: закрыты три дыры в покрытии, найденные мутационным анализом
This commit is contained in:
@@ -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
@@ -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. Смысловой поиск (вектор работает)
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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}" \
|
||||
|
||||
Reference in New Issue
Block a user