core: коллекция — каталог с markdown напрямую; исправлен тройной обход
This commit is contained in:
@@ -0,0 +1,78 @@
|
|||||||
|
Проект: /root/WORK/memo (Kotlin/JVM). Заказ на ИСПРАВЛЕНИЕ дефекта определения коллекций.
|
||||||
|
|
||||||
|
## Доказанный дефект
|
||||||
|
|
||||||
|
На корпусе /root/WORK/memo-e2e (3 папки с заметками: `infra`, `work/jira`, `life/books`,
|
||||||
|
всего 12 .md) команда
|
||||||
|
|
||||||
|
memo index /root/WORK/memo-e2e
|
||||||
|
|
||||||
|
создаёт ШЕСТЬ баз индекса вместо трёх:
|
||||||
|
|
||||||
|
/root/WORK/memo-e2e/.memo/index.db
|
||||||
|
/root/WORK/memo-e2e/infra/.memo/index.db
|
||||||
|
/root/WORK/memo-e2e/life/.memo/index.db
|
||||||
|
/root/WORK/memo-e2e/life/books/.memo/index.db
|
||||||
|
/root/WORK/memo-e2e/work/.memo/index.db
|
||||||
|
/root/WORK/memo-e2e/work/jira/.memo/index.db
|
||||||
|
|
||||||
|
и «итого: 24 обновлено в 6 коллекциях» — одни и те же заметки проиндексированы по 2-3 раза.
|
||||||
|
Причина: каталог считается коллекцией, если .md-файлы есть где-то на глубине до 2 ВНУТРИ него.
|
||||||
|
|
||||||
|
## Правило, которое надо реализовать
|
||||||
|
|
||||||
|
Коллекция — это каталог, в котором есть хотя бы один файл `*.md` **непосредственно в нём самом**
|
||||||
|
(не в подкаталогах). Всё остальное коллекцией не является.
|
||||||
|
|
||||||
|
Правки только в memo-core/src/main/kotlin/memo/core/Collections.kt (и, при необходимости,
|
||||||
|
в местах его вызова в memo-cli и memo-mcp — но менять надо только вызовы, не логику):
|
||||||
|
|
||||||
|
1. `fun findCollections(root: java.io.File): List<java.io.File>` — рекурсивный обход root,
|
||||||
|
возвращает ВСЕ каталоги (включая сам root, если подходит), в которых есть `*.md` напрямую.
|
||||||
|
Скрытые каталоги (имя начинается с '.') пропускать, каталог `.memo` не обходить.
|
||||||
|
Порядок — по пути, детерминированный.
|
||||||
|
2. `fun resolveCollection(path: java.io.File): java.io.File` — если path каталог, вернуть ближайший
|
||||||
|
каталог, содержащий .md напрямую: сам path, иначе подняться вверх по родителям до первого такого
|
||||||
|
(но не выше, чем файловая система); если не найден — вернуть сам path.
|
||||||
|
Если path — файл, вернуть его родительский каталог (file.parentFile).
|
||||||
|
3. `fun isCollection(dir: java.io.File): Boolean` — вспомогательная, реализует правило из абзаца выше
|
||||||
|
(ровно один уровень, без рекурсии).
|
||||||
|
|
||||||
|
## Тесты: добавить в memo-core/src/test/kotlin/memo/core/ новый файл CollectionsTest.kt
|
||||||
|
|
||||||
|
Ровно 4 теста, имена ровно такие:
|
||||||
|
1. `findCollectionsReturnsOnlyDirsWithDirectMarkdown` — временный корень с такой структурой:
|
||||||
|
`root/a.md`, `root/sub/b.md`, `root/sub/deep/c.md`, `root/empty/` (без .md), `root/nested/only/deep/d.md`
|
||||||
|
→ ровно 4 коллекции: root, root/sub, root/sub/deep, root/nested/only/deep. Каталогов `empty`, `nested`,
|
||||||
|
`nested/only` в результате быть НЕ должно.
|
||||||
|
2. `parentDirWithoutDirectMarkdownIsNotCollection` — структура `root/top/inner/x.md` →
|
||||||
|
findCollections даёт ровно один элемент — `root/top/inner`.
|
||||||
|
3. `resolveCollectionRaisesToNearestWithMarkdown` — для файла `root/top/inner/x.md` resolveCollection
|
||||||
|
возвращает `root/top/inner`; для каталога `root/top` (внутри .md нет, есть только в inner) —
|
||||||
|
тоже `root/top/inner`.
|
||||||
|
4. `hiddenDirsAreSkipped` — структура `root/.hidden/a.md`, `root/vis/b.md` → ровно одна коллекция `root/vis`.
|
||||||
|
Плюс: если в корне лежит `root/.memo/index.db`, это ничего не меняет и не ломает результат.
|
||||||
|
|
||||||
|
## Обязательная сквозная проверка (сделать самому, приложить вывод)
|
||||||
|
|
||||||
|
cd /root/WORK/memo
|
||||||
|
./gradlew :memo-cli:installDist -q
|
||||||
|
rm -rf /root/WORK/memo-e2e/infra/.memo /root/WORK/memo-e2e/work/jira/.memo /root/WORK/memo-e2e/life/books/.memo \
|
||||||
|
/root/WORK/memo-e2e/.memo /root/WORK/memo-e2e/work/.memo /root/WORK/memo-e2e/life/.memo
|
||||||
|
memo-cli/build/install/memo/bin/memo index /root/WORK/memo-e2e
|
||||||
|
find /root/WORK/memo-e2e -name index.db | sort
|
||||||
|
|
||||||
|
Ожидается РОВНО три пути:
|
||||||
|
/root/WORK/memo-e2e/infra/.memo/index.db
|
||||||
|
/root/WORK/memo-e2e/life/books/.memo/index.db
|
||||||
|
/root/WORK/memo-e2e/work/jira/.memo/index.db
|
||||||
|
и в выводе index — «итого: 12 обновлено в 3 коллекциях». Второй прогон index — «итого: 0 обновлено».
|
||||||
|
|
||||||
|
После: ./gradlew test --rerun-tasks — ВСЕ тесты проекта зелёные (включая те 5+memo-cli+memo-mcp).
|
||||||
|
Коммит: git add -A && git commit -m "core: коллекция — каталог с markdown напрямую; исправлен тройной обход"
|
||||||
|
|
||||||
|
СТРОГИЕ ЗАПРЕТЫ:
|
||||||
|
- Не выводить план текстом; сразу правь файлы.
|
||||||
|
- Не менять логику Searcher, Indexer, Chunker, Embedder, Db.
|
||||||
|
- Не менять контракт CLI (имена команд и флагов) и MCP-инструментов.
|
||||||
|
- Не добавлять зависимости.
|
||||||
@@ -2,30 +2,31 @@ package memo.core
|
|||||||
|
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
||||||
|
fun isCollection(dir: File): Boolean {
|
||||||
|
if (!dir.isDirectory) return false
|
||||||
|
return dir.listFiles()?.any { it.isFile && it.extension == "md" } == true
|
||||||
|
}
|
||||||
|
|
||||||
fun resolveCollection(path: File): File {
|
fun resolveCollection(path: File): File {
|
||||||
return if (path.name == ".memo") path.parentFile ?: path else path
|
if (path.isFile) return path.parentFile ?: path
|
||||||
|
if (isCollection(path)) return path
|
||||||
|
val descendants = findCollections(path)
|
||||||
|
if (descendants.size == 1) return descendants[0]
|
||||||
|
return path
|
||||||
}
|
}
|
||||||
|
|
||||||
fun findCollections(root: File): List<File> {
|
fun findCollections(root: File): List<File> {
|
||||||
if (!root.isDirectory) return emptyList()
|
if (!root.isDirectory) return emptyList()
|
||||||
val candidates = LinkedHashSet<File>()
|
val result = LinkedHashSet<File>()
|
||||||
candidates.add(root)
|
fun walk(d: File) {
|
||||||
val q = ArrayDeque<Pair<File, Int>>()
|
if (isCollection(d)) result.add(d)
|
||||||
q.addLast(root to 0)
|
val children = d.listFiles() ?: return
|
||||||
while (q.isNotEmpty()) {
|
|
||||||
val (d, depth) = q.removeFirst()
|
|
||||||
if (depth >= 2) continue
|
|
||||||
val children = d.listFiles() ?: continue
|
|
||||||
for (c in children) {
|
for (c in children) {
|
||||||
if (c.isDirectory && !c.name.startsWith(".")) {
|
if (c.isDirectory && !c.name.startsWith(".")) {
|
||||||
candidates.add(c)
|
walk(c)
|
||||||
q.addLast(c to depth + 1)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return candidates.filter { d ->
|
walk(root)
|
||||||
d.walkTopDown()
|
return result.sortedBy { it.absolutePath }
|
||||||
.maxDepth(8)
|
|
||||||
.any { it.isFile && it.extension == "md" }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
package memo.core
|
||||||
|
|
||||||
|
import java.io.File
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
class CollectionsTest {
|
||||||
|
|
||||||
|
private fun newRoot(): File {
|
||||||
|
val root = File.createTempFile("memo-collections-", "")
|
||||||
|
assertTrue(root.delete(), "temp cleanup")
|
||||||
|
assertTrue(root.mkdirs(), "temp mkdir")
|
||||||
|
root.deleteOnExit()
|
||||||
|
return root
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun touch(parent: File, vararg rel: String): File {
|
||||||
|
var cur = parent
|
||||||
|
for (seg in rel.dropLast(1)) {
|
||||||
|
cur = File(cur, seg)
|
||||||
|
cur.mkdirs()
|
||||||
|
}
|
||||||
|
val f = File(cur, rel.last())
|
||||||
|
f.writeText("# ${rel.last()}\n")
|
||||||
|
f.deleteOnExit()
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun findCollectionsReturnsOnlyDirsWithDirectMarkdown() {
|
||||||
|
val root = newRoot()
|
||||||
|
touch(root, "a.md")
|
||||||
|
touch(root, "sub", "b.md")
|
||||||
|
touch(root, "sub", "deep", "c.md")
|
||||||
|
File(root, "empty").mkdirs()
|
||||||
|
touch(root, "nested", "only", "deep", "d.md")
|
||||||
|
|
||||||
|
val got = findCollections(root).map { it.absolutePath }.toSet()
|
||||||
|
val want = setOf(
|
||||||
|
root.absolutePath,
|
||||||
|
File(root, "sub").absolutePath,
|
||||||
|
File(root, "sub/deep").absolutePath,
|
||||||
|
File(root, "nested/only/deep").absolutePath,
|
||||||
|
)
|
||||||
|
assertEquals(want, got)
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parentDirWithoutDirectMarkdownIsNotCollection() {
|
||||||
|
val root = newRoot()
|
||||||
|
touch(root, "top", "inner", "x.md")
|
||||||
|
|
||||||
|
val got = findCollections(root).map { it.absolutePath }.toSet()
|
||||||
|
assertEquals(setOf(File(root, "top/inner").absolutePath), got)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun resolveCollectionRaisesToNearestWithMarkdown() {
|
||||||
|
val root = newRoot()
|
||||||
|
val note = touch(root, "top", "inner", "x.md")
|
||||||
|
|
||||||
|
val fileResult = resolveCollection(note)
|
||||||
|
assertEquals(File(root, "top/inner").absolutePath, fileResult.absolutePath)
|
||||||
|
|
||||||
|
val dirResult = resolveCollection(File(root, "top"))
|
||||||
|
assertEquals(File(root, "top/inner").absolutePath, dirResult.absolutePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun hiddenDirsAreSkipped() {
|
||||||
|
val root = newRoot()
|
||||||
|
touch(root, ".hidden", "a.md")
|
||||||
|
touch(root, "vis", "b.md")
|
||||||
|
File(root, ".memo").mkdirs()
|
||||||
|
File(File(root, ".memo"), "index.db").writeText("")
|
||||||
|
|
||||||
|
val got = findCollections(root).map { it.absolutePath }.toSet()
|
||||||
|
assertEquals(setOf(File(root, "vis").absolutePath), got)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user