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 <agent@opencode.local>
This commit is contained in:
Sergey 2026-07-29 17:20:59 +03:00 committed by GitHub
parent d724e29f96
commit 7249f1fcb5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 532 additions and 26 deletions

View file

@ -74,6 +74,9 @@ jobs:
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version: '22' 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 - uses: astral-sh/setup-uv@v3
- run: uv sync --extra dev --python ${{ matrix.python }} - run: uv sync --extra dev --python ${{ matrix.python }}
- run: uv run --python ${{ matrix.python }} pytest - run: uv run --python ${{ matrix.python }} pytest

View file

@ -1,5 +1,6 @@
import { spawnSync } from "child_process" import { spawnSync } from "child_process"
import fs from "fs" import fs from "fs"
import os from "os"
import path from "path" import path from "path"
import { tool } from "@opencode-ai/plugin" import { tool } from "@opencode-ai/plugin"
import { resolveMemoryDir, resolveRgBinary, walkMd } from "./_memory-shared" import { resolveMemoryDir, resolveRgBinary, walkMd } from "./_memory-shared"
@ -8,21 +9,61 @@ function mark(ok: boolean): string {
return ok ? "✅" : "❌" 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[] = [] const lines: string[] = []
if (rgBin) { if (rgBin) {
const r = spawnSync(rgBin, ["--version"], { encoding: "utf-8" }) const r = spawnSync(rgBin, ["--version"], { encoding: "utf-8" })
const ver = (r.stdout || "").split("\n")[0].trim() const ver = (r.stdout || "").split("\n")[0].trim()
lines.push(`- ${mark(r.status === 0)} ripgrep (keyword search): ${rgBin}${ver ? `${ver}` : ""}`) const works = r.status === 0
} else { 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 }
}
// rgBin === null: npm package missing — try system rg as a hint.
const sys = spawnSync("rg", ["--version"], { encoding: "utf-8" }) const sys = spawnSync("rg", ["--version"], { encoding: "utf-8" })
if (sys.status === 0) { if (sys.status === 0) {
lines.push(`- ${mark(true)} ripgrep (keyword search): system rg on $PATH`) lines.push(`- ${mark(true)} ripgrep (keyword search): system rg on $PATH`)
} else { return { lines, rgWorks: true }
lines.push(`- ❌ ripgrep (keyword search): NOT resolvable — install @vscode/ripgrep or system rg`)
} }
} lines.push(
return lines "- ❌ 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 } { function checkPythonMemory(worktree: string): { lines: string[]; ok: boolean } {
@ -93,13 +134,17 @@ function checkIndex(memoryDir: string): string[] {
return lines 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 indexJson = path.join(memoryDir, ".rag", "index.json")
const allGreen = 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 (allGreen) return "All green — both keyword and semantic search available."
if (rgBin !== null) return "Keyword search works. Semantic search unavailable — see ❌ items above." if (rgWorks) return "Keyword search works. Semantic search unavailable — see ❌ items above."
return "ripgrep missing — keyword search will return no results. Install @vscode/ripgrep." return "ripgrep missing or broken — keyword search will return no results. Install @vscode/ripgrep or system rg."
} }
export default tool({ export default tool({
@ -112,8 +157,9 @@ export default tool({
const memoryDir = resolveMemoryDir() const memoryDir = resolveMemoryDir()
const lines: string[] = ["## Memory Doctor\n"] const lines: string[] = ["## Memory Doctor\n"]
const rgBin = resolveRgBinary() const rgBin = resolveRgBinary({ allowSystemFallback: true })
lines.push(...checkRipgrep(rgBin)) const rg = checkRipgrep(rgBin)
lines.push(...rg.lines)
const py = checkPythonMemory(context.worktree) const py = checkPythonMemory(context.worktree)
lines.push(...py.lines) lines.push(...py.lines)
@ -122,7 +168,7 @@ export default tool({
lines.push(...checkIndex(memoryDir)) lines.push(...checkIndex(memoryDir))
lines.push("") lines.push("")
lines.push(formatDoctorReport(rgBin, py.ok, memoryDir)) lines.push(formatDoctorReport(rg.rgWorks, py.ok, memoryDir))
return lines.join("\n") return lines.join("\n")
}, },
}) })

View file

@ -311,6 +311,10 @@ export default tool({
let resultMap = new Map<string, CandInfo>() let resultMap = new Map<string, CandInfo>()
if (rgBin) { if (rgBin) {
resultMap = runKeywordSearch(rgBin, rgTerms, searchDir, memoryDir) 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") const indexDir = path.join(memoryDir, ".rag")
@ -321,6 +325,10 @@ export default tool({
if (rgBin) { if (rgBin) {
const fallback = runKeywordSearch(rgBin, rgTerms, memoryDir, memoryDir) const fallback = runKeywordSearch(rgBin, rgTerms, memoryDir, memoryDir)
for (const [k, v] of fallback) resultMap.set(k, v) 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) mergeRagHits(resultMap, ragHits)
} }

View file

@ -13,6 +13,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
gnupg \ gnupg \
docker.io \ docker.io \
ffmpeg \ ffmpeg \
ripgrep \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
RUN curl -LsSf https://astral.sh/uv/install.sh | UV_INSTALL_DIR=/usr/local/bin sh RUN curl -LsSf https://astral.sh/uv/install.sh | UV_INSTALL_DIR=/usr/local/bin sh

View file

@ -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 стандартизированы).

View file

@ -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 упадёт).

View file

@ -10,7 +10,7 @@ Root `AGENTS.md` — orchestrator directive (chat = plan only, all via subagents
opencode-config/ opencode-config/
├── .github/ ├── .github/
│ ├── workflows/ │ ├── 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) │ │ ├── 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) │ │ └── adr-check.yml # ADR cross-reference validator (.opencode/scripts/check-adr-refs.py)
│ └── dependabot.yml # pip + github-actions ecosystem updates │ └── 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 │ │ ├── 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 │ │ ├── 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-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-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-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 │ │ ├── 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-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 │ │ ├── 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_embedder_live.py # Live embed tests (skip without RUN_LIVE=1)
│ ├── test_index.py # src/memory/index.py (chunking + env override + .rag skip) │ ├── test_index.py # src/memory/index.py (chunking + env override + .rag skip)
│ ├── test_chunking.py # _chunk_text edge cases (empty, unicode, size<overlap) │ ├── test_chunking.py # _chunk_text edge cases (empty, unicode, size<overlap)
│ ├── test_memory_tools_e2e.py # E2E hybrid memory (4 scenarios: zero-config keyword-only, full hybrid, OpenRouter fallback, deletion; @pytest.mark.skipif(not RUN_LIVE); subprocess-based: ripgrep + python3 -m src.memory) — PR#102 │ ├── test_memory_doctor.ts # TS unit-тесты для memory-doctor (allGreen false при битом бинарнике G9, allGreen true regression guard, arch-mismatch detection G2, npm-missing hint G3) — PR#124
│ ├── test_memory_keyword_search.ts # TS unit-тесты для resolveRgBinary (npm-package, system-fallback, null-nothing, null-no-fallback) + memory-search stderr warning при rgBin===null — PR#124
│ ├── test_memory_tools_e2e.py # E2E hybrid memory (4 scenarios: zero-config keyword-only [БЕЗ RUN_LIVE], full hybrid, OpenRouter fallback, deletion [individual skipif(not RUN_LIVE)]; subprocess-based: ripgrep + python3 -m src.memory) — PR#102, PR#124
│ ├── test_merge_pr_tool.py # .opencode/tools/merge-pr.ts (via _ts_loader.mjs; 8 tests: base + repo explicit/omitted/invalid) — PR#65 │ ├── test_merge_pr_tool.py # .opencode/tools/merge-pr.ts (via _ts_loader.mjs; 8 tests: base + repo explicit/omitted/invalid) — PR#65
│ ├── test_merge_pr_tool.ts # TS wrapper test (mjs loader; 5 tests: base + repo) — PR#65 │ ├── test_merge_pr_tool.ts # TS wrapper test (mjs loader; 5 tests: base + repo) — PR#65
│ ├── test_observability.py # .opencode/scripts/observability.py │ ├── test_observability.py # .opencode/scripts/observability.py
@ -123,7 +125,7 @@ opencode-config/
├── uv.lock # Locked deps for Python project ├── uv.lock # Locked deps for Python project
├── .pre-commit-config.yaml # ruff + UV hooks ├── .pre-commit-config.yaml # ruff + UV hooks
├── docker-compose.yml # 2 services (dind + opencode), opencode_network, 4 bind mounts, port 4096 on 0.0.0.0 — PR#24, PR#51 ├── docker-compose.yml # 2 services (dind + opencode), opencode_network, 4 bind mounts, port 4096 on 0.0.0.0 — PR#24, PR#51
├── Dockerfile # node:22-trixie-slim (Debian 13 trixie, glibc 2.41) + uv + gh + chromium + docker.io + opencode-ai + repomix + cloudflared (@mathew-cf/opencode-memory REMOVED PR#103) — PR#24, PR#34, PR#57, PR#71, PR#103 ├── Dockerfile # node:22-trixie-slim (Debian 13 trixie, glibc 2.41) + uv + gh + chromium + docker.io + opencode-ai + repomix + cloudflared + ripgrep (apt fallback для keyword search) (@mathew-cf/opencode-memory REMOVED PR#103) — PR#24, PR#34, PR#57, PR#71, PR#103, PR#124
│ # Memory deps install layers (PR#107): COPY .opencode/package.json → npm install --omit=dev (runtime: @vscode/ripgrep for memory-search.ts); COPY pyproject.toml uv.lock → uv sync --no-dev --frozen (runtime: httpx, numpy, tenacity for python -m src.memory) │ # Memory deps install layers (PR#107): COPY .opencode/package.json → npm install --omit=dev (runtime: @vscode/ripgrep for memory-search.ts); COPY pyproject.toml uv.lock → uv sync --no-dev --frozen (runtime: httpx, numpy, tenacity for python -m src.memory)
├── .env.example # Placeholder-only env template (user copies to .env) — PR#24, PR#34 (TUNNEL_DOMAIN), PR#36 (OPENCODE_MEMORY_REMOTE/DIR), PR#106 (AI_PROVIDER_* removed, OPENCODE_MEMORY_REMOTE now optional), PR#118 (OPENCODE_SERVER_USERNAME) ├── .env.example # Placeholder-only env template (user copies to .env) — PR#24, PR#34 (TUNNEL_DOMAIN), PR#36 (OPENCODE_MEMORY_REMOTE/DIR), PR#106 (AI_PROVIDER_* removed, OPENCODE_MEMORY_REMOTE now optional), PR#118 (OPENCODE_SERVER_USERNAME)
├── app_data/ ├── app_data/

169
tests/test_memory_doctor.ts Normal file
View file

@ -0,0 +1,169 @@
/**
* Tests for memory-doctor.ts diagnostic gaps.
*
* memory-doctor runs ripgrep --version and reports status. This file covers
* the four gaps fixed in PR for issue #123:
* G9 allGreen must check rg --version status===0, not just rgBin!==null
* G2 arch-mismatch detection when binary platform != runtime platform
* G3 npm require-error surfaced in report when npm package missing
*
* 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_allGreen_false_on_broken_binary rgPath exists but --version
* fails report says NOT all green (G9)
* - test_arch_mismatch_detection rgBin path contains 'darwin' but
* process.platform === 'linux' report mentions arch mismatch (G2)
* - test_npm_missing_hint_in_report rgBin===null, no system rg report
* includes npm ci hint (G3)
* - test_allGreen_true_when_rg_works rg --version succeeds keyword
* works (sanity, G9 regression guard)
*/
import { describe, test, expect, mock } from "bun:test" with { type: "'bun-test'" }
import fs from "fs"
import os from "os"
import path from "path"
const DOCTOR_SRC = path.resolve(
import.meta.dir,
"..",
".opencode",
"tools",
"memory-doctor.ts"
)
function ctx(worktree = ".") {
return {
sessionID: "t",
messageID: "t",
agent: "t",
directory: ".",
worktree,
abort: new AbortController().signal,
metadata() {},
async ask() {},
}
}
describe("memory-doctor allGreen (G9)", () => {
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")
})
})

View file

@ -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")
})
})

View file

@ -8,11 +8,6 @@ from pathlib import Path
import pytest import pytest
pytestmark = pytest.mark.skipif(
not os.environ.get("RUN_LIVE"),
reason="requires real OpenRouter API",
)
REPO_ROOT = Path(__file__).resolve().parent.parent REPO_ROOT = Path(__file__).resolve().parent.parent
UNIQUE_TERM = "zzuniqtestterm42" UNIQUE_TERM = "zzuniqtestterm42"
SYNONYM = "unique test concept" SYNONYM = "unique test concept"
@ -162,6 +157,10 @@ def test_zero_config_keyword_only(tmp_path: Path) -> None:
@pytest.mark.timeout(180) @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: def test_full_hybrid_semantic_and_keyword(tmp_path: Path) -> None:
"""Scenario 2: semantic finds by synonym, keyword finds by exact term.""" """Scenario 2: semantic finds by synonym, keyword finds by exact term."""
memory_dir = tmp_path / "memory" 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.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: def test_fallback_openrouter_down(tmp_path: Path) -> None:
"""Scenario 3: keyword works when OpenRouter returns 401.""" """Scenario 3: keyword works when OpenRouter returns 401."""
memory_dir = tmp_path / "memory" memory_dir = tmp_path / "memory"
@ -209,6 +212,10 @@ def test_fallback_openrouter_down(tmp_path: Path) -> None:
@pytest.mark.timeout(180) @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: def test_file_deletion(tmp_path: Path) -> None:
"""Scenario 4: after deletion + reindex, search returns [].""" """Scenario 4: after deletion + reindex, search returns []."""
memory_dir = tmp_path / "memory" memory_dir = tmp_path / "memory"