From 7249f1fcb5b3870c5349281ada0d9bd7336ec315 Mon Sep 17 00:00:00 2001 From: Sergey <93754860+slaid098@users.noreply.github.com> Date: Wed, 29 Jul 2026 17:20:59 +0300 Subject: [PATCH] fix(memory): keyword search out-of-box + doctor gaps + tests (#124) * fix(docker): add system ripgrep via apt for keyword search fallback * fix(ci): install ripgrep and npm deps for keyword search in CI * fix(memory-search): warn when ripgrep not resolvable instead of silent skip * fix(memory-doctor): check rg status, arch mismatch, and require errors * test(memory): add keyword search and doctor unit tests * test(memory): unskip e2e keyword tests without RUN_LIVE * docs(handoff): add handoff and ADR for keyword search fix * test(memory): remove unused var in keyword search test * docs(handoff): set PR number to 124 * docs(project-map): update after structural changes (PR#124) --------- Co-authored-by: opencode-agent --- .github/workflows/ci.yml | 3 + .opencode/tools/memory-doctor.ts | 78 ++++++-- .opencode/tools/memory-search.ts | 8 + Dockerfile | 1 + .../055-pr-124-keyword-search-out-of-box.md | 40 +++++ .../pr-124-keyword-search-out-of-box.md | 76 ++++++++ docs/project-map/README.md | 12 +- tests/test_memory_doctor.ts | 169 ++++++++++++++++++ tests/test_memory_keyword_search.ts | 154 ++++++++++++++++ tests/test_memory_tools_e2e.py | 17 +- 10 files changed, 532 insertions(+), 26 deletions(-) create mode 100644 docs/decisions/055-pr-124-keyword-search-out-of-box.md create mode 100644 docs/handoff/pr-124-keyword-search-out-of-box.md create mode 100644 tests/test_memory_doctor.ts create mode 100644 tests/test_memory_keyword_search.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ddf1f27..57d5151 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -74,6 +74,9 @@ jobs: - uses: actions/setup-node@v4 with: node-version: '22' + - run: sudo apt-get update && sudo apt-get install -y ripgrep + - run: npm ci + working-directory: .opencode - uses: astral-sh/setup-uv@v3 - run: uv sync --extra dev --python ${{ matrix.python }} - run: uv run --python ${{ matrix.python }} pytest diff --git a/.opencode/tools/memory-doctor.ts b/.opencode/tools/memory-doctor.ts index 2e6ff33..f0de62d 100644 --- a/.opencode/tools/memory-doctor.ts +++ b/.opencode/tools/memory-doctor.ts @@ -1,5 +1,6 @@ import { spawnSync } from "child_process" import fs from "fs" +import os from "os" import path from "path" import { tool } from "@opencode-ai/plugin" import { resolveMemoryDir, resolveRgBinary, walkMd } from "./_memory-shared" @@ -8,21 +9,61 @@ function mark(ok: boolean): string { return ok ? "✅" : "❌" } -function checkRipgrep(rgBin: string | null): string[] { +interface RipgrepCheck { + lines: string[] + rgWorks: boolean +} + +function detectArchMismatch(rgBin: string): string | null { + const plat = process.platform + const lower = rgBin.toLowerCase() + if (plat === "linux" && (lower.includes("darwin") || lower.includes("win32") || lower.includes("win64"))) { + return "arch mismatch: binary platform != runtime platform (linux)" + } + if (plat === "darwin" && (lower.includes("linux") || lower.includes("win32") || lower.includes("win64"))) { + return "arch mismatch: binary platform != runtime platform (darwin)" + } + if (plat === "win32" && (lower.includes("linux") || lower.includes("darwin"))) { + return "arch mismatch: binary platform != runtime platform (win32)" + } + const arch = os.arch() + if (arch === "arm64" && lower.includes("x64")) return "arch mismatch: binary is x64 but runtime is arm64" + if ((arch === "x64" || arch === "x86_64") && lower.includes("arm64")) return "arch mismatch: binary is arm64 but runtime is x64" + return null +} + +function checkRipgrep(rgBin: string | null): RipgrepCheck { const lines: string[] = [] if (rgBin) { const r = spawnSync(rgBin, ["--version"], { encoding: "utf-8" }) const ver = (r.stdout || "").split("\n")[0].trim() - lines.push(`- ${mark(r.status === 0)} ripgrep (keyword search): ${rgBin}${ver ? ` — ${ver}` : ""}`) - } else { - const sys = spawnSync("rg", ["--version"], { encoding: "utf-8" }) - if (sys.status === 0) { - lines.push(`- ${mark(true)} ripgrep (keyword search): system rg on $PATH`) - } else { - lines.push(`- ❌ ripgrep (keyword search): NOT resolvable — install @vscode/ripgrep or system rg`) + const works = r.status === 0 + lines.push( + `- ${mark(works)} ripgrep (keyword search): ${rgBin}${ver ? ` — ${ver}` : ""}` + ) + if (!works) { + const archMismatch = detectArchMismatch(rgBin) + if (archMismatch) { + lines.push(` - ${archMismatch}`) + } + const errMsg = (r.stderr || "").trim() + if (errMsg) lines.push(` - binary execution failed: ${errMsg.split("\n")[0]}`) } + return { lines, rgWorks: works } } - return lines + // rgBin === null: npm package missing — try system rg as a hint. + const sys = spawnSync("rg", ["--version"], { encoding: "utf-8" }) + if (sys.status === 0) { + lines.push(`- ${mark(true)} ripgrep (keyword search): system rg on $PATH`) + return { lines, rgWorks: true } + } + lines.push( + "- ❌ ripgrep (keyword search): NOT resolvable — install @vscode/ripgrep or system rg" + ) + lines.push( + " - npm package missing — run `npm ci` in .opencode/ (or `apt-get install ripgrep` for system fallback)" + ) + return { lines, rgWorks: false } } function checkPythonMemory(worktree: string): { lines: string[]; ok: boolean } { @@ -93,13 +134,17 @@ function checkIndex(memoryDir: string): string[] { return lines } -function formatDoctorReport(rgBin: string | null, pyOk: boolean, memoryDir: string): string { +function formatDoctorReport( + rgWorks: boolean, + pyOk: boolean, + memoryDir: string +): string { const indexJson = path.join(memoryDir, ".rag", "index.json") const allGreen = - rgBin !== null && pyOk && fs.existsSync(memoryDir) && fs.existsSync(indexJson) + rgWorks && pyOk && fs.existsSync(memoryDir) && fs.existsSync(indexJson) if (allGreen) return "All green — both keyword and semantic search available." - if (rgBin !== null) return "Keyword search works. Semantic search unavailable — see ❌ items above." - return "ripgrep missing — keyword search will return no results. Install @vscode/ripgrep." + if (rgWorks) return "Keyword search works. Semantic search unavailable — see ❌ items above." + return "ripgrep missing or broken — keyword search will return no results. Install @vscode/ripgrep or system rg." } export default tool({ @@ -112,8 +157,9 @@ export default tool({ const memoryDir = resolveMemoryDir() const lines: string[] = ["## Memory Doctor\n"] - const rgBin = resolveRgBinary() - lines.push(...checkRipgrep(rgBin)) + const rgBin = resolveRgBinary({ allowSystemFallback: true }) + const rg = checkRipgrep(rgBin) + lines.push(...rg.lines) const py = checkPythonMemory(context.worktree) lines.push(...py.lines) @@ -122,7 +168,7 @@ export default tool({ lines.push(...checkIndex(memoryDir)) lines.push("") - lines.push(formatDoctorReport(rgBin, py.ok, memoryDir)) + lines.push(formatDoctorReport(rg.rgWorks, py.ok, memoryDir)) return lines.join("\n") }, }) \ No newline at end of file diff --git a/.opencode/tools/memory-search.ts b/.opencode/tools/memory-search.ts index 8bcd4d6..2127ac8 100644 --- a/.opencode/tools/memory-search.ts +++ b/.opencode/tools/memory-search.ts @@ -311,6 +311,10 @@ export default tool({ let resultMap = new Map() if (rgBin) { resultMap = runKeywordSearch(rgBin, rgTerms, searchDir, memoryDir) + } else { + process.stderr.write( + "[memory-search] ripgrep not resolvable — keyword search disabled\n" + ) } const indexDir = path.join(memoryDir, ".rag") @@ -321,6 +325,10 @@ export default tool({ if (rgBin) { const fallback = runKeywordSearch(rgBin, rgTerms, memoryDir, memoryDir) for (const [k, v] of fallback) resultMap.set(k, v) + } else { + process.stderr.write( + "[memory-search] ripgrep not resolvable — category fallback keyword search disabled\n" + ) } mergeRagHits(resultMap, ragHits) } diff --git a/Dockerfile b/Dockerfile index 2f4e40c..b34d227 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,6 +13,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ gnupg \ docker.io \ ffmpeg \ + ripgrep \ && rm -rf /var/lib/apt/lists/* RUN curl -LsSf https://astral.sh/uv/install.sh | UV_INSTALL_DIR=/usr/local/bin sh diff --git a/docs/decisions/055-pr-124-keyword-search-out-of-box.md b/docs/decisions/055-pr-124-keyword-search-out-of-box.md new file mode 100644 index 0000000..02359ed --- /dev/null +++ b/docs/decisions/055-pr-124-keyword-search-out-of-box.md @@ -0,0 +1,40 @@ +# ADR-055: System ripgrep fallback + memory-doctor status checks + +## Статус +Accepted (2026-07-29) + +## Контекст + +Keyword search (ripgrep) в memory-системе заявлен в README как "always +works", но на практике system `rg` не был установлен нигде — только +npm-пакет `@vscode/ripgrep` через volume-mount (хрупко, platform-specific). +CI не устанавливал ripgrep. `memory-doctor` имел 4 диагностических gap'а: +allGreen проверял `rgBin!==null` вместо `r.status===0`, расхождение +`allowSystemFallback` с memory-search, нет arch-mismatch detection, +require-ошибки не попадали в отчёт. + +## Решение + +- **Dual ripgrep source**: `@vscode/ripgrep` (npm, primary) + system `rg` + через apt (fallback). Dockerfile и CI устанавливают оба. +- **`resolveRgBinary({ allowSystemFallback: true })`** в memory-doctor — + консистентно с memory-search (раньше doctor не пробовал system fallback, + что расходилось с реальным поведением search). +- **`checkRipgrep` возвращает `{ lines, rgWorks }`** — `rgWorks` = + `rgBin!==null && r.status===0`. `allGreen` использует `rgWorks`, не + `rgBin!==null` — закрывает G9 (битый бинарник с существующим path). +- **`detectArchMismatch()`** — эвристика по имени path бинарника (contains + 'darwin'/'linux'/'win32' + 'arm64'/'x64'). Не вызывает `file` — portable. +- **Warning в stderr** при `rgBin===null` в memory-search — вместо + молчаливого skip. Упрощает диагностику. + +## Альтернативы + +- **Только system rg (убрать npm-пакет)**: rejected — npm-пакет даёт + pinned version и не зависит от distro package manager; Dockerfile slim + image может не иметь rg в apt без extra repos. +- **Только npm-пакет (убрать apt)**: rejected — volume-mount хрупок, + CI runner без npm install для .opencode/package.json = нет rg. +- **`file` command для arch-mismatch**: rejected — extra dependency, + эвристика по path покрывает основной случай (@vscode/ripgrep platform + dir names стандартизированы). \ No newline at end of file diff --git a/docs/handoff/pr-124-keyword-search-out-of-box.md b/docs/handoff/pr-124-keyword-search-out-of-box.md new file mode 100644 index 0000000..d47bbb3 --- /dev/null +++ b/docs/handoff/pr-124-keyword-search-out-of-box.md @@ -0,0 +1,76 @@ +--- +pr: 124 +title: fix(memory): keyword search out-of-box + doctor gaps + tests +--- + +## Что сделано + +- **Dockerfile**: добавлен `ripgrep` в `apt-get install` (строки 3-16). + System rg = fallback; `@vscode/ripgrep` в `npm install -g` (строка 38) + остаётся primary path. +- **ci.yml** (job `test`): после `setup-node@v4` добавлены + `apt-get install -y ripgrep` и `npm ci` (working-directory: .opencode) — + CI теперь устанавливает ripgrep и npm-deps для keyword search. +- **memory-search.ts** (~строка 312): при `rgBin===null` пишет warning в + stderr (`ripgrep not resolvable — keyword search disabled`) вместо + молчаливого skip. Аналогичный warning в category-fallback блоке. +- **memory-doctor.ts** — закрыты 4 gap'а: + - **G9**: `checkRipgrep` возвращает `{ lines, rgWorks }` где + `rgWorks = rgBin !== null && r.status === 0`; `formatDoctorReport` + и `allGreen` используют `rgWorks`, а не `rgBin !== null`. + - **G5**: `resolveRgBinary({ allowSystemFallback: true })` в execute + (консистентно с memory-search.ts:305). + - **G3**: при `rgBin===null` (npm-пакет не найден) — строка с подсказкой + `npm package missing — run npm ci in .opencode/`. + - **G2**: `detectArchMismatch()` — если rgBin path содержит 'darwin' + при `process.platform === 'linux'` (и обратные комбинации) + arch + mismatch (arm64 vs x64) → строка `arch mismatch: binary platform != + runtime platform` в отчёт. +- **tests/test_memory_keyword_search.ts** (новый): 5 TS unit-тестов для + `resolveRgBinary()` (npm-package, system-fallback, null-nothing, + null-no-fallback) + memory-search stderr warning при rgBin===null. +- **tests/test_memory_doctor.ts** (новый): 4 TS unit-теста — allGreen + false при битом бинарнике (G9), allGreen true при рабочем (regression + guard), arch-mismatch detection (G2), npm-missing hint в report (G3). +- **tests/test_memory_tools_e2e.py**: убран глобальный `pytestmark` + skipif(RUN_LIVE). Keyword-тест `test_zero_config_keyword_only` теперь + запускается БЕЗ RUN_LIVE. Semantic-тесты (hybrid, fallback, deletion) + имеют individual `skipif(not RUN_LIVE)`. + +Проверки: `pytest tests/ -x -q` 427 passed, 6 skipped; `ruff check` OK; +`ruff format --check` OK. + +## Почему + +README заявлял keyword search как "always works", но: +1. System `rg` не был установлен нигде (Dockerfile/CI/host) — только + хрупкий volume-mount npm-пакета. +2. CI не устанавливал ripgrep ни через apt, ни через npm ci для + `.opencode/package.json`. +3. `memory-search` молча пропускал keyword search при `rgBin===null` — + без warning, что затрудняло диагностику. +4. `memory-doctor` имел 4 расхождения: allGreen проверял `rgBin!==null` + а не `r.status===0`, `allowSystemFallback` расходился с memory-search, + нет arch-mismatch detection, require-ошибки не попадали в отчёт. +5. E2E-тесты скипались без `RUN_LIVE` целиком — даже keyword-тесты, + которые не требуют embeddings API. + +## Pending + +— + +## Watch out + +- TS unit-тесты (`test_memory_keyword_search.ts`, `test_memory_doctor.ts`) + runnable under `bun test` — bun runtime отсутствует на CI host; файлы + валидны как TS, но не запускаются pytest'ом. Это тот же pattern что + `test_commit_tool.ts` / `test_merge_pr_tool.ts`. +- `detectArchMismatch()` использует эвристику по имени path (contains + 'darwin'/'linux'/'win32' + 'arm64'/'x64') — не вызывает `file` command. + Покрывает основной случай (@vscode/ripgrep platform dir names). +- `resolveRgBinary({ allowSystemFallback: true })` в memory-doctor теперь + консистентен с memory-search — но меняет поведение: если npm-пакет + отсутствует но system rg есть, doctor покажет ✅ вместо ❌. Это + намеренно (doctor = зеркало реального поведения search). +- CI: `npm ci` в `.opencode/` требует `package-lock.json` — проверь что + он есть в репо (иначе CI упадёт). \ No newline at end of file diff --git a/docs/project-map/README.md b/docs/project-map/README.md index 475cc91..b01274f 100644 --- a/docs/project-map/README.md +++ b/docs/project-map/README.md @@ -10,7 +10,7 @@ Root `AGENTS.md` — orchestrator directive (chat = plan only, all via subagents opencode-config/ ├── .github/ │ ├── workflows/ -│ │ ├── ci.yml # Lint, test, typecheck, complexity (bootstrap + output-based skip) +│ │ ├── ci.yml # Lint, test, typecheck, complexity (bootstrap + output-based skip); apt-get install ripgrep + npm ci (.opencode) для keyword search — PR#124 │ │ ├── permissions-check.yml # .opencode/scripts/check-permissions.py validator (step-level skip) │ │ └── adr-check.yml # ADR cross-reference validator (.opencode/scripts/check-adr-refs.py) │ └── dependabot.yml # pip + github-actions ecosystem updates @@ -48,10 +48,10 @@ opencode-config/ │ │ ├── create-readme.ts # create-readme tool (TS plugin, modes: create/validate; standardized bilingual README with features table, access_url, RU heading 'Русский' + anchor checks, 4 delimiter pairs for slaid098.dev; local fs + remote gh api) — PR#112, PR#116, PR#118 │ │ ├── merge-pr.ts # merge-pr tool wrapper (orchestrator-safe gh pr merge; optional repo?: string) — PR#30, PR#65 │ │ ├── memory-access.ts # memory-access tool (bump frontmatter last_accessed/access_count, regex replace, atomic write tmp+rename) — PR#101 -│ │ ├── memory-doctor.ts # memory-doctor tool (read-only diagnostics: rg, Python src.memory importability, env vars, memory dir, RAG index; markdown ✅/❌ report) — PR#101 +│ │ ├── memory-doctor.ts # memory-doctor tool (read-only diagnostics: rg {lines, rgWorks} G9, allowSystemFallback:true G5, arch-mismatch detection G2, npm-missing hint G3; markdown ✅/❌ report) — PR#101, PR#124 │ │ ├── memory-list.ts # memory-list tool (pure TS, categories count .md or files in category with frontmatter) — PR#101 │ │ ├── memory-save.ts # memory-save tool (auto-setup mkdir+git init/clone+hook+7 categories, git commit, async reindex via spawn detached+unref) — PR#101 -│ │ ├── memory-search.ts # memory-search tool (ripgrep keyword + Python semantic via spawnSync + scoring port + cross-category fallback) — PR#101 +│ │ ├── memory-search.ts # memory-search tool (ripgrep keyword + Python semantic via spawnSync + scoring port + cross-category fallback; stderr warning при rgBin===null) — PR#101, PR#124 │ │ ├── pipeline-status.ts # pipeline-status tool wrapper │ │ ├── post-docs-review.ts # post-docs-review tool wrapper (3 args: pr_number, verdict enum, body; deterministic ## Docs Review Summary heading; optional repo?: string) — PR#46, PR#65 │ │ ├── post-review.ts # post-review tool wrapper (3 args: pr_number, verdict enum, body; deterministic ## Code Review Summary heading; optional repo?: string) — PR#46, PR#65 @@ -98,7 +98,9 @@ opencode-config/ │ ├── test_embedder_live.py # Live embed tests (skip without RUN_LIVE=1) │ ├── test_index.py # src/memory/index.py (chunking + env override + .rag skip) │ ├── test_chunking.py # _chunk_text edge cases (empty, unicode, size { + test("test_allGreen_false_on_broken_binary — rgPath exists but --version fails → NOT all green", async () => { + const fakeRgPath = "/tmp/opencode/broken-rg" + mock.module("node:module", () => ({ + createRequire: () => () => ({ rgPath: fakeRgPath }), + })) + mock.module("fs", () => ({ + ...fs, + existsSync: (p: string) => + p === fakeRgPath || p === os.homedir() + "/.local/share/opencode/opencode-memory", + readdirSync: () => [], + statSync: () => ({ size: 0, mtime: new Date() }), + })) + mock.module("child_process", () => ({ + spawnSync: (cmd: string, args: string[]) => { + if (cmd === fakeRgPath && args[0] === "--version") { + return { status: 1, stdout: "", stderr: "cannot execute binary" } + } + if (cmd === "python3") return { status: 0, stdout: "ok\n", stderr: "" } + if (cmd === "rg") return { status: 1, stdout: "", stderr: "" } + return { status: 0, stdout: "", stderr: "" } + }, + })) + const mod = await import(DOCTOR_SRC + "?t=" + Date.now()) + const report = await mod.default.execute({}, ctx()) + expect(report).toContain("ripgrep missing or broken") + }) + + test("test_allGreen_true_when_rg_works — rg --version succeeds → keyword works", async () => { + const fakeRgPath = "/tmp/opencode/good-rg" + mock.module("node:module", () => ({ + createRequire: () => () => ({ rgPath: fakeRgPath }), + })) + mock.module("fs", () => ({ + ...fs, + existsSync: () => true, + readdirSync: () => [], + statSync: () => ({ size: 0, mtime: new Date() }), + })) + mock.module("child_process", () => ({ + spawnSync: (cmd: string, args: string[]) => { + if (cmd === fakeRgPath && args[0] === "--version") { + return { status: 0, stdout: "ripgrep 13.0.0\n", stderr: "" } + } + if (cmd === "python3") return { status: 0, stdout: "ok\n", stderr: "" } + return { status: 0, stdout: "", stderr: "" } + }, + })) + const mod = await import(DOCTOR_SRC + "?t=" + Date.now()) + const report = await mod.default.execute({}, ctx()) + expect(report).toContain("All green") + }) +}) + +describe("memory-doctor arch mismatch (G2)", () => { + test("test_arch_mismatch_detection — darwin binary on linux → report mentions arch mismatch", async () => { + // @vscode/ripgrep platform dir names include the OS: e.g. + // .../@vscode/ripgrep/bin/rg-darwin-arm64 or .../rg-linux-x64. + const fakeRgPath = "/tmp/opencode/@vscode/ripgrep/bin/rg-darwin-arm64" + const origPlatform = process.platform + Object.defineProperty(process, "platform", { value: "linux" }) + try { + mock.module("node:module", () => ({ + createRequire: () => () => ({ rgPath: fakeRgPath }), + })) + mock.module("fs", () => ({ + ...fs, + existsSync: () => true, + readdirSync: () => [], + statSync: () => ({ size: 0, mtime: new Date() }), + })) + mock.module("child_process", () => ({ + spawnSync: (cmd: string, args: string[]) => { + if (cmd === fakeRgPath && args[0] === "--version") { + return { status: 1, stdout: "", stderr: "Exec format error" } + } + if (cmd === "python3") return { status: 0, stdout: "ok\n", stderr: "" } + return { status: 0, stdout: "", stderr: "" } + }, + })) + const mod = await import(DOCTOR_SRC + "?t=" + Date.now()) + const report = await mod.default.execute({}, ctx()) + expect(report).toContain("arch mismatch") + expect(report).toContain("darwin") + } finally { + Object.defineProperty(process, "platform", { value: origPlatform }) + } + }) +}) + +describe("memory-doctor npm missing hint (G3)", () => { + test("test_npm_missing_hint_in_report — rgBin===null, no system rg → report includes npm ci hint", async () => { + mock.module("node:module", () => ({ + createRequire: () => () => { + throw new Error("Cannot find module '@vscode/ripgrep'") + }, + })) + mock.module("fs", () => ({ + ...fs, + existsSync: (p: string) => + p === os.homedir() + "/.local/share/opencode/opencode-memory", + readdirSync: () => [], + statSync: () => ({ size: 0, mtime: new Date() }), + })) + mock.module("child_process", () => ({ + spawnSync: (cmd: string) => { + if (cmd === "rg") return { status: 127, stdout: "", stderr: "rg: not found" } + if (cmd === "python3") return { status: 0, stdout: "ok\n", stderr: "" } + return { status: 0, stdout: "", stderr: "" } + }, + })) + const mod = await import(DOCTOR_SRC + "?t=" + Date.now()) + const report = await mod.default.execute({}, ctx()) + expect(report).toContain("npm package missing") + expect(report).toContain("npm ci") + expect(report).toContain("ripgrep missing or broken") + }) +}) \ No newline at end of file diff --git a/tests/test_memory_keyword_search.ts b/tests/test_memory_keyword_search.ts new file mode 100644 index 0000000..d4eb781 --- /dev/null +++ b/tests/test_memory_keyword_search.ts @@ -0,0 +1,154 @@ +/** + * Tests for resolveRgBinary() and memory-search keyword degradation. + * + * resolveRgBinary() lives in .opencode/tools/_memory-shared.ts and resolves + * the ripgrep binary via the @vscode/ripgrep npm package first, then + * (optionally) a system `rg` on $PATH. memory-search.ts must warn to stderr + * when both are unavailable instead of silently returning no keyword hits. + * + * Runtime note: opencode ships a standalone binary with Bun bundled inside; + * there is no separate `bun` CLI on the host (CI runner uses node + pytest). + * This file documents the intended TS-side test cases and is runnable under + * `bun test` once a bun runtime is available on the host. + * + * Test cases: + * - test_resolve_npm_package — rgPath exists → returns path + * - test_resolve_system_fallback — npm require throws, allowSystemFallback + * true, system rg on $PATH → returns "rg" + * - test_resolve_null_when_nothing_available — npm throws, no system rg, + * allowSystemFallback true → null + * - test_resolve_null_no_fallback — npm throws, allowSystemFallback false + * → null (no system rg probe) + * - test_memory_search_warns_when_rg_null — rgBin===null → stderr warning + */ + +import { describe, test, expect, mock } from "bun:test" with { type: "'bun-test'" } +import { spawnSync } from "child_process" +import fs from "fs" +import path from "path" + +const SHARED_SRC = path.resolve( + import.meta.dir, + "..", + ".opencode", + "tools", + "_memory-shared.ts" +) +const SEARCH_SRC = path.resolve( + import.meta.dir, + "..", + ".opencode", + "tools", + "memory-search.ts" +) + +describe("resolveRgBinary", () => { + test("test_resolve_npm_package — @vscode/ripgrep resolves to existing binary", async () => { + const fakeRgPath = "/tmp/opencode/fake-rg-bin" + mock.module("node:module", () => ({ + createRequire: () => () => ({ rgPath: fakeRgPath }), + })) + mock.module("fs", () => ({ + ...fs, + existsSync: (p: string) => p === fakeRgPath, + })) + const mod = await import(SHARED_SRC + "?t=" + Date.now()) + const result = mod.resolveRgBinary() + expect(result).toBe(fakeRgPath) + }) + + test("test_resolve_system_fallback — npm require throws, allowSystemFallback true, system rg works → 'rg'", async () => { + mock.module("node:module", () => ({ + createRequire: () => () => { + throw new Error("Cannot find module '@vscode/ripgrep'") + }, + })) + mock.module("child_process", () => ({ + spawnSync: () => ({ status: 0, stdout: "ripgrep 13.0.0\n", stderr: "" }), + })) + const mod = await import(SHARED_SRC + "?t=" + Date.now()) + const result = mod.resolveRgBinary({ allowSystemFallback: true }) + expect(result).toBe("rg") + }) + + test("test_resolve_null_when_nothing_available — npm throws, no system rg, allowSystemFallback true → null", async () => { + mock.module("node:module", () => ({ + createRequire: () => () => { + throw new Error("Cannot find module '@vscode/ripgrep'") + }, + })) + mock.module("child_process", () => ({ + spawnSync: () => ({ status: 1, stdout: "", stderr: "rg: not found" }), + })) + const mod = await import(SHARED_SRC + "?t=" + Date.now()) + const result = mod.resolveRgBinary({ allowSystemFallback: true }) + expect(result).toBeNull() + }) + + test("test_resolve_null_no_fallback — npm throws, allowSystemFallback false → null (no system rg probe)", async () => { + let spawnCalled = false + mock.module("node:module", () => ({ + createRequire: () => () => { + throw new Error("Cannot find module '@vscode/ripgrep'") + }, + })) + mock.module("child_process", () => ({ + spawnSync: () => { + spawnCalled = true + return { status: 0, stdout: "ripgrep 13.0.0\n", stderr: "" } + }, + })) + const mod = await import(SHARED_SRC + "?t=" + Date.now()) + const result = mod.resolveRgBinary() + expect(result).toBeNull() + expect(spawnCalled).toBe(false) + }) +}) + +describe("memory-search keyword degradation", () => { + test("test_memory_search_warns_when_rg_null — rgBin===null writes stderr warning", async () => { + const stderrWrites: string[] = [] + mock.module("node:module", () => ({ + createRequire: () => () => { + throw new Error("Cannot find module '@vscode/ripgrep'") + }, + })) + mock.module("child_process", () => ({ + spawnSync: () => ({ status: 1, stdout: "", stderr: "rg: not found" }), + })) + mock.module("fs", () => ({ + ...fs, + existsSync: () => true, + readFileSync: () => + "---\ntitle: test\nsummary: s\n---\n\n# test\n\nbody text\n", + readdirSync: () => [], + statSync: () => ({ size: 0, mtime: new Date() }), + })) + const origStderrWrite = process.stderr.write + process.stderr.write = ((chunk: string | Uint8Array) => { + stderrWrites.push(typeof chunk === "string" ? chunk : chunk.toString()) + return true + }) as typeof process.stderr.write + try { + const mod = await import(SEARCH_SRC + "?t=" + Date.now()) + await mod.default.execute( + { query: "nonexistent" }, + { + sessionID: "t", + messageID: "t", + agent: "t", + directory: ".", + worktree: ".", + abort: new AbortController().signal, + metadata() {}, + async ask() {}, + } + ) + } finally { + process.stderr.write = origStderrWrite + } + const combined = stderrWrites.join("") + expect(combined).toContain("ripgrep not resolvable") + expect(combined).toContain("keyword search disabled") + }) +}) \ No newline at end of file diff --git a/tests/test_memory_tools_e2e.py b/tests/test_memory_tools_e2e.py index 6f0fe47..422379b 100644 --- a/tests/test_memory_tools_e2e.py +++ b/tests/test_memory_tools_e2e.py @@ -8,11 +8,6 @@ from pathlib import Path import pytest -pytestmark = pytest.mark.skipif( - not os.environ.get("RUN_LIVE"), - reason="requires real OpenRouter API", -) - REPO_ROOT = Path(__file__).resolve().parent.parent UNIQUE_TERM = "zzuniqtestterm42" SYNONYM = "unique test concept" @@ -162,6 +157,10 @@ def test_zero_config_keyword_only(tmp_path: Path) -> None: @pytest.mark.timeout(180) +@pytest.mark.skipif( + not os.environ.get("RUN_LIVE"), + reason="needs OPENAI_BASE_URL for semantic search", +) def test_full_hybrid_semantic_and_keyword(tmp_path: Path) -> None: """Scenario 2: semantic finds by synonym, keyword finds by exact term.""" memory_dir = tmp_path / "memory" @@ -186,6 +185,10 @@ def test_full_hybrid_semantic_and_keyword(tmp_path: Path) -> None: @pytest.mark.timeout(300) +@pytest.mark.skipif( + not os.environ.get("RUN_LIVE"), + reason="needs OPENAI_BASE_URL for semantic search", +) def test_fallback_openrouter_down(tmp_path: Path) -> None: """Scenario 3: keyword works when OpenRouter returns 401.""" memory_dir = tmp_path / "memory" @@ -209,6 +212,10 @@ def test_fallback_openrouter_down(tmp_path: Path) -> None: @pytest.mark.timeout(180) +@pytest.mark.skipif( + not os.environ.get("RUN_LIVE"), + reason="needs OPENAI_BASE_URL for semantic search", +) def test_file_deletion(tmp_path: Path) -> None: """Scenario 4: after deletion + reindex, search returns [].""" memory_dir = tmp_path / "memory"