From 8d97527cc20feaa5e53f6e8afbdf27d219cff782 Mon Sep 17 00:00:00 2001 From: William Thorsen Date: Mon, 27 Jul 2026 15:14:32 -0700 Subject: [PATCH 1/9] agents|refactor: Reach ripgrep through an injectable process runner Running the recall tests no longer requires ripgrep on PATH. Recall now fails loudly when ripgrep reports matches whose output it cannot parse, rather than returning the same empty result a vault with nothing to find would produce. --- .../src/kb-search/__tests__/recall.test.ts | 192 +++++++++++++----- packages/agents/src/kb-search/recall.ts | 70 +++++-- 2 files changed, 185 insertions(+), 77 deletions(-) diff --git a/packages/agents/src/kb-search/__tests__/recall.test.ts b/packages/agents/src/kb-search/__tests__/recall.test.ts index 9fcc036c..eb539c8d 100644 --- a/packages/agents/src/kb-search/__tests__/recall.test.ts +++ b/packages/agents/src/kb-search/__tests__/recall.test.ts @@ -1,7 +1,9 @@ import { join } from 'node:path'; -import { describe, expect, it } from 'vitest'; +import type { Mock } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; +import type { ProcessRunner } from '../recall.ts'; import { parseRipgrepOutput, recallNotes } from '../recall.ts'; import type { ScopedKb } from '../types.ts'; @@ -10,101 +12,151 @@ const NOTES_VAULT = join(import.meta.dirname, 'fixtures', 'notes-vault'); const notesVaultScope: ScopedKb[] = [{ name: 'notes', path: NOTES_VAULT, via: 'discovery' }]; describe(recallNotes, () => { - it('returns notes whose body contains a query term', async () => { - const { hits } = await recallNotes({ query: 'backpressure', scopedKbs: notesVaultScope }); - - expect(matchedBasenames(hits)).toEqual(['streams.md']); + it('invokes ripgrep with the markdown glob, the .kb exclusion, the context window, and the JSON format', async () => { + const runner = vi.fn().mockResolvedValue({ stdout: '' }); + + await recallNotes({ query: 'backpressure', scopedKbs: notesVaultScope, runner }); + + expect(runner).toHaveBeenCalledWith('rg', [ + '--ignore-case', + '--glob', + '*.md', + '--glob', + '!.kb/**', + '--context', + '1', + '--json', + 'backpressure', + NOTES_VAULT, + ]); }); - it('returns a note stored under a digit-prefixed directory', async () => { - // A path segment such as `2024-archive` must not be misread as a ripgrep content line. - const { hits } = await recallNotes({ query: 'quobble', scopedKbs: notesVaultScope }); + it('escapes regex metacharacters so a query term matches literally', async () => { + const runner = vi.fn().mockResolvedValue({ stdout: '' }); + + await recallNotes({ query: 'c++', scopedKbs: notesVaultScope, runner }); - expect(matchedBasenames(hits)).toEqual(['legacy-runbook.md']); + expect(readPattern(runner)).toBe(String.raw`c\+\+`); }); - it('returns a note whose filename is date-patterned', async () => { - // A filename such as 2026-05-01-meeting-notes.md must not have its `-05-` run misread as ripgrep line number. - const { hits } = await recallNotes({ query: 'flummox', scopedKbs: notesVaultScope }); + it('combines query terms disjunctively in a single pattern', async () => { + const runner = vi.fn().mockResolvedValue({ stdout: '' }); - expect(matchedBasenames(hits)).toEqual(['2026-05-01-meeting-notes.md']); + await recallNotes({ query: 'backpressure dependency', scopedKbs: notesVaultScope, runner }); + + expect(readPattern(runner)).toBe('backpressure|dependency'); }); - it('returns a note stored under a date-patterned directory', async () => { - // A directory such as `2026-06-01` must not have its `-06-` run misread as the ripgrep line-number field. - const { hits } = await recallNotes({ query: 'grumbletwist', scopedKbs: notesVaultScope }); + it('expands an alias query term to its canonical tag', async () => { + // "node" is an alias for the canonical tag "nodejs"; notes carry canonical tags only, so the alias alone + // would never match one. + const runner = vi.fn().mockResolvedValue({ stdout: '' }); - expect(matchedBasenames(hits)).toEqual(['daily-log.md']); + await recallNotes({ query: 'node', scopedKbs: notesVaultScope, runner }); + + expect(readPattern(runner)).toBe('node|nodejs'); }); it('attributes each hit to its source KB name and path', async () => { - const { hits } = await recallNotes({ query: 'backpressure', scopedKbs: notesVaultScope }); - - expect(hits[0]?.kbName).toBe('notes'); - expect(hits[0]?.kbPath).toBe(NOTES_VAULT); + const runner = vi + .fn() + .mockResolvedValue({ stdout: buildRipgrepOutput([[join(NOTES_VAULT, 'streams.md'), 'notes on backpressure']]) }); + + const { hits } = await recallNotes({ query: 'backpressure', scopedKbs: notesVaultScope, runner }); + + expect(hits).toEqual([ + { + path: join(NOTES_VAULT, 'streams.md'), + kbName: 'notes', + kbPath: NOTES_VAULT, + snippet: 'notes on backpressure', + }, + ]); }); it('propagates a null kbName for a discovered KB with no registry entry', async () => { const scope: ScopedKb[] = [{ name: null, path: NOTES_VAULT, via: 'discovery' }]; - const { hits } = await recallNotes({ query: 'backpressure', scopedKbs: scope }); + const runner = vi + .fn() + .mockResolvedValue({ stdout: buildRipgrepOutput([[join(NOTES_VAULT, 'streams.md'), 'backpressure']]) }); + + const { hits } = await recallNotes({ query: 'backpressure', scopedKbs: scope, runner }); expect(hits[0]?.kbName).toBeNull(); }); - it('treats each query term disjunctively', async () => { - const { hits } = await recallNotes({ query: 'backpressure dependency', scopedKbs: notesVaultScope }); + it('returns no hits when ripgrep exits 1 to report that nothing matched', async () => { + const runner = vi.fn().mockRejectedValue(buildProcessError(1)); - expect(matchedBasenames(hits)).toEqual(['hooks.md', 'streams.md']); - }); - - it('expands an alias query term to its canonical tag', async () => { - // "node" is an alias for the canonical tag "nodejs"; the streams note is tagged "nodejs" only. - const { hits } = await recallNotes({ query: 'node', scopedKbs: notesVaultScope }); + const { hits } = await recallNotes({ query: 'zzzznomatch', scopedKbs: notesVaultScope, runner }); - expect(matchedBasenames(hits)).toContain('streams.md'); + expect(hits).toEqual([]); }); - it('returns an empty array when no note matches', async () => { - const { hits } = await recallNotes({ query: 'zzzznomatch', scopedKbs: notesVaultScope }); + it('throws a remediation hint when the ripgrep binary cannot be spawned', async () => { + const runner = vi.fn().mockRejectedValue(buildProcessError('ENOENT')); - expect(hits).toEqual([]); + await expect(recallNotes({ query: 'backpressure', scopedKbs: notesVaultScope, runner })).rejects.toThrow( + /requires ripgrep/, + ); }); - it('returns an empty array for a blank query', async () => { - const { hits } = await recallNotes({ query: ' '.repeat(3), scopedKbs: notesVaultScope }); + it('rethrows a ripgrep failure that is neither a no-match exit nor an absent binary', async () => { + const runner = vi.fn().mockRejectedValue(buildProcessError(2)); - expect(hits).toEqual([]); + await expect(recallNotes({ query: 'backpressure', scopedKbs: notesVaultScope, runner })).rejects.toThrow( + /mock process failure: 2/, + ); }); - it('captures a context snippet for each matched note', async () => { - const { hits } = await recallNotes({ query: 'backpressure', scopedKbs: notesVaultScope }); + it('throws when ripgrep reports matches but none of its output can be parsed', async () => { + // An unreadable --json shape must not degrade to "no matches", which would report an empty vault to the reader + // while recall is in fact broken. + const runner = vi.fn().mockResolvedValue({ stdout: '{"type":"match","data":{"unexpected":true}}' }); - expect(hits[0]?.snippet).toContain('backpressure'); + await expect(recallNotes({ query: 'backpressure', scopedKbs: notesVaultScope, runner })).rejects.toThrow( + /--json event format/, + ); }); - it('returns a single hit and a capped snippet for a note with multiple matches', async () => { - // multi-match.md carries the unique term on two non-adjacent lines, so ripgrep emits more than three line events - // for the file. - // The note must surface as one hit (not duplicated per match), and its snippet must include only the 1st match's - // window; the 2nd match falls beyond the `SNIPPET_CONTEXT_LINES * 2 + 1` = 3 line cap in `parseRipgrepOutput`. - const { hits } = await recallNotes({ query: 'thunderfish', scopedKbs: notesVaultScope }); + it('runs no search at all for a blank query', async () => { + const runner = vi.fn(); + + const { hits } = await recallNotes({ query: ' '.repeat(3), scopedKbs: notesVaultScope, runner }); - expect(matchedBasenames(hits)).toEqual(['multi-match.md']); - expect(hits[0]?.snippet).toContain('first thunderfish'); - expect(hits[0]?.snippet).not.toContain('second thunderfish'); + expect(hits).toEqual([]); + expect(runner).not.toHaveBeenCalled(); }); it('skips a scoped KB whose path does not exist and reports it in missingKbs', async () => { const missing: ScopedKb = { name: 'missing', path: join(NOTES_VAULT, 'no-such-dir'), via: 'registry-all' }; - const scope: ScopedKb[] = [missing, ...notesVaultScope]; - const { hits, missingKbs } = await recallNotes({ query: 'backpressure', scopedKbs: scope }); + const runner = vi + .fn() + .mockResolvedValue({ stdout: buildRipgrepOutput([[join(NOTES_VAULT, 'streams.md'), 'backpressure']]) }); + + const { hits, missingKbs } = await recallNotes({ + query: 'backpressure', + scopedKbs: [missing, ...notesVaultScope], + runner, + }); - expect(matchedBasenames(hits)).toEqual(['streams.md']); + expect(hits.map((hit) => hit.kbName)).toEqual(['notes']); expect(missingKbs).toEqual([missing]); + expect(runner).toHaveBeenCalledTimes(1); }); }); describe(parseRipgrepOutput, () => { + it('reads a note path from the structured field rather than from the line text', () => { + // Digit runs in a path such as 2026-06-01/2026-05-01-meeting-notes.md are what a line-oriented parser misread as + // ripgrep's line-number field. The --json events carry the path in its own field, so the ambiguity cannot arise. + const stream = buildRipgrepOutput([['/vault/2026-06-01/2026-05-01-meeting-notes.md', 'grumbletwist']]); + + expect(parseRipgrepOutput(stream)).toEqual([ + { path: '/vault/2026-06-01/2026-05-01-meeting-notes.md', snippet: 'grumbletwist' }, + ]); + }); + it('skips a malformed JSON line and still returns valid matches', () => { // A line that is not valid JSON must be dropped silently, so that a single corrupted event does not lose the // surrounding valid matches in the same stream. @@ -119,9 +171,39 @@ describe(parseRipgrepOutput, () => { expect(entries).toEqual([{ path: './a.md', snippet: 'hello world' }]); }); + + it('returns one entry per note and caps its snippet at the first match window', () => { + // A note matching on several non-adjacent lines emits more than three line events. It must surface once, with a + // snippet drawn from the first match and its neighbors only. + const stream = buildRipgrepOutput([ + ['./multi.md', 'first thunderfish'], + ['./multi.md', 'neighbor one'], + ['./multi.md', 'neighbor two'], + ['./multi.md', 'second thunderfish'], + ]); + + const entries = parseRipgrepOutput(stream); + + expect(entries).toHaveLength(1); + expect(entries[0]?.snippet).toBe('first thunderfish neighbor one neighbor two'); + }); }); -/** Collects the basenames of the matched note paths for order-independent assertions. */ -function matchedBasenames(hits: ReadonlyArray<{ path: string }>): string[] { - return hits.map((hit) => hit.path.split('/').at(-1) ?? '').toSorted(); +/** Builds a child-process error carrying `code`, the shape `execFile` rejects with on a bad exit or a failed spawn. */ +function buildProcessError(code: number | string): Error & { code: number | string } { + return Object.assign(new Error(`mock process failure: ${code}`), { code }); +} + +/** Builds ripgrep `--json` stdout carrying one match event per `[notePath, lineText]` pair. */ +function buildRipgrepOutput(matches: ReadonlyArray): string { + return matches + .map(([path, text]) => + JSON.stringify({ type: 'match', data: { path: { text: path }, lines: { text: `${text}\n` } } }), + ) + .join('\n'); +} + +/** Reads the search pattern out of the recorded ripgrep invocation: the argument preceding the search directory. */ +function readPattern(runner: Mock): string | undefined { + return runner.mock.calls[0]?.[1].at(-2); } diff --git a/packages/agents/src/kb-search/recall.ts b/packages/agents/src/kb-search/recall.ts index 7c417112..89afa515 100644 --- a/packages/agents/src/kb-search/recall.ts +++ b/packages/agents/src/kb-search/recall.ts @@ -11,6 +11,16 @@ import type { RawHit, ScopedKb } from './types.ts'; const execFileAsync = promisify(execFile); +/** + * Runs a process and resolves its captured stdout, rejecting with an error carrying `code` on a non-zero exit or a + * failed spawn. Recall reaches ripgrep only through this seam, so tests assert the arguments it is called with and + * drive the exit-code and missing-binary branches without spawning anything. + */ +export type ProcessRunner = (command: string, args: readonly string[]) => Promise<{ stdout: string }>; + +/** Output cap for one ripgrep invocation, sized well past the match set of a large vault. */ +const RIPGREP_MAX_BUFFER = 32 * 1024 * 1024; + /** Number of context lines captured on each side of a ripgrep match for the snippet. */ const SNIPPET_CONTEXT_LINES = 1; @@ -33,14 +43,20 @@ export interface RecallResult { * An in-scope KB whose path is absent (`ENOENT` / `ENOTDIR`) is skipped and reported in `missingKbs` so that callers * can surface the dead path; a permission error (`EACCES` / `EPERM`) on a path that does exist still throws. * - * ripgrep is required on `PATH`; an absent binary throws with a remediation hint. + * ripgrep is required on `PATH`; an absent binary throws with a remediation hint. `runner` overrides how the process is + * reached, defaulting to a real `rg` invocation. */ -export async function recallNotes(input: { query: string; scopedKbs: ScopedKb[] }): Promise { +export async function recallNotes(input: { + query: string; + scopedKbs: ScopedKb[]; + runner?: ProcessRunner; +}): Promise { const baseTerms = tokenizeQuery(input.query); if (baseTerms.length === 0) { return { hits: [], missingKbs: [] }; } + const runner = input.runner ?? runRipgrepProcess; const hits: RawHit[] = []; const missingKbs: ScopedKb[] = []; for (const kb of input.scopedKbs) { @@ -50,7 +66,7 @@ export async function recallNotes(input: { query: string; scopedKbs: ScopedKb[] } const aliases = await loadAliasesForKb(kb.path); const terms = expandTerms(baseTerms, aliases); - const kbHits = await searchKb({ kb, terms }); + const kbHits = await searchKb({ kb, terms, runner }); hits.push(...kbHits); } return { hits, missingKbs }; @@ -185,24 +201,20 @@ export function parseRipgrepOutput(stdout: string): Array<{ path: string; snippe } /** Invokes ripgrep over `*.md` files and return its stdout; an empty match set yields an empty string. */ -async function runRipgrep(input: { pattern: string; searchDir: string }): Promise { +async function runRipgrep(input: { pattern: string; searchDir: string; runner: ProcessRunner }): Promise { try { - const { stdout } = await execFileAsync( - 'rg', - [ - '--ignore-case', - '--glob', - '*.md', - '--glob', - `!${KB_DIR}/**`, - '--context', - String(SNIPPET_CONTEXT_LINES), - '--json', - input.pattern, - input.searchDir, - ], - { maxBuffer: 32 * 1024 * 1024 }, - ); + const { stdout } = await input.runner('rg', [ + '--ignore-case', + '--glob', + '*.md', + '--glob', + `!${KB_DIR}/**`, + '--context', + String(SNIPPET_CONTEXT_LINES), + '--json', + input.pattern, + input.searchDir, + ]); return stdout; } catch (error) { // ripgrep exits 1 when no matches are found — that is an empty result, not a failure. @@ -216,12 +228,26 @@ async function runRipgrep(input: { pattern: string; searchDir: string }): Promis } } +/** The default {@link ProcessRunner}: spawns the real binary, capping its output at {@link RIPGREP_MAX_BUFFER}. */ +async function runRipgrepProcess(command: string, args: readonly string[]): Promise<{ stdout: string }> { + return execFileAsync(command, [...args], { maxBuffer: RIPGREP_MAX_BUFFER }); +} + /** Runs a single ripgrep invocation across one KB and collect its hits, de-duplicated by note path. */ -async function searchKb(input: { kb: ScopedKb; terms: string[] }): Promise { +async function searchKb(input: { kb: ScopedKb; terms: string[]; runner: ProcessRunner }): Promise { const pattern = input.terms.map(escapeRegExp).join('|'); - const stdout = await runRipgrep({ pattern, searchDir: input.kb.path }); + const stdout = await runRipgrep({ pattern, searchDir: input.kb.path, runner: input.runner }); const matches = parseRipgrepOutput(stdout); + // ripgrep exits 1 when nothing matched, which `runRipgrep` maps to an empty string, so output here means it found + // something. Parsing none of it therefore means the `--json` event shape no longer matches what this module reads. + // Reporting that as "no matches" would hide a broken recall behind an ordinary-looking empty result. + if (stdout.trim() !== '' && matches.length === 0) { + throw new Error( + `ripgrep reported matches in ${input.kb.path} but none of its output could be parsed; its --json event format may have changed`, + ); + } + const byPath = new Map(); for (const match of matches) { if (byPath.has(match.path)) { From 59e34f0b4d96f6c809c3b3fc1ee14e7db73fc1eb Mon Sep 17 00:00:00 2001 From: William Thorsen Date: Mon, 27 Jul 2026 15:18:29 -0700 Subject: [PATCH 2/9] agents|refactor: Accept an injected recall in searchNotes and the CLIs Running the search tests no longer requires ripgrep on PATH. A test names the notes recall found instead of relying on a query matching the right fixture notes, so what it asserts about scoping, filtering, and projection no longer depends on the matcher. --- packages/agents/src/kb-retrieve-events/cli.ts | 3 ++ packages/agents/src/kb-retrieve/cli.ts | 3 ++ .../src/kb-search/__tests__/search.test.ts | 25 +++++++++-- packages/agents/src/kb-search/recall.ts | 6 +++ packages/agents/src/kb-search/search.ts | 8 +++- .../kb-search/test-utils/build-recall-stub.ts | 41 +++++++++++++++++++ 6 files changed, 80 insertions(+), 6 deletions(-) create mode 100644 packages/agents/src/kb-search/test-utils/build-recall-stub.ts diff --git a/packages/agents/src/kb-retrieve-events/cli.ts b/packages/agents/src/kb-retrieve-events/cli.ts index a8f6abe8..6e699298 100644 --- a/packages/agents/src/kb-retrieve-events/cli.ts +++ b/packages/agents/src/kb-retrieve-events/cli.ts @@ -6,6 +6,7 @@ import { fileURLToPath } from 'node:url'; import { EVENT_IMPACT_LEVELS, type EventImpact, isEventImpact } from '@codeassembly/kb/records'; +import type { RecallFn } from '../kb-search/recall.ts'; import { recordTypeOf, searchNotes } from '../kb-search/search.ts'; import type { RecallFilters } from '../kb-search/types.ts'; import { type FlagSpec, scanFlags, valueFlagMap } from '../lib/parse-flags.ts'; @@ -117,6 +118,7 @@ export async function runRetrieveEvents(input: { argv: readonly string[]; startDir: string; home?: string; + recall?: RecallFn; }): Promise { const { query, allKbs, storeName, filters, minImpact } = parseArgs(input.argv); @@ -131,6 +133,7 @@ export async function runRetrieveEvents(input: { startDir: input.startDir, ...(storeName !== null && { storeName }), ...(input.home !== undefined && { home: input.home }), + ...(input.recall !== undefined && { recall: input.recall }), }); if (search.emptyScopeDiagnostic !== undefined) { diff --git a/packages/agents/src/kb-retrieve/cli.ts b/packages/agents/src/kb-retrieve/cli.ts index 4e7a25fe..d438e401 100644 --- a/packages/agents/src/kb-retrieve/cli.ts +++ b/packages/agents/src/kb-retrieve/cli.ts @@ -4,6 +4,7 @@ import { realpathSync } from 'node:fs'; import process from 'node:process'; import { fileURLToPath } from 'node:url'; +import type { RecallFn } from '../kb-search/recall.ts'; import { recordTypeOf, searchNotes } from '../kb-search/search.ts'; import type { RecallFilters } from '../kb-search/types.ts'; import { type FlagSpec, scanFlags, valueFlagMap } from '../lib/parse-flags.ts'; @@ -124,6 +125,7 @@ export async function runRetrieve(input: { startDir: string; now: Date; home?: string; + recall?: RecallFn; }): Promise { const { query, allKbs, storeName, filters } = parseArgs(input.argv); @@ -138,6 +140,7 @@ export async function runRetrieve(input: { startDir: input.startDir, ...(storeName !== null && { storeName }), ...(input.home !== undefined && { home: input.home }), + ...(input.recall !== undefined && { recall: input.recall }), }); if (search.emptyScopeDiagnostic !== undefined) { diff --git a/packages/agents/src/kb-search/__tests__/search.test.ts b/packages/agents/src/kb-search/__tests__/search.test.ts index 6845b336..4fd5fad6 100644 --- a/packages/agents/src/kb-search/__tests__/search.test.ts +++ b/packages/agents/src/kb-search/__tests__/search.test.ts @@ -2,7 +2,9 @@ import { join } from 'node:path'; import { describe, expect, it, vi } from 'vitest'; +import type { RecallFn } from '../recall.ts'; import { searchNotes } from '../search.ts'; +import { buildRecallStub } from '../test-utils/build-recall-stub.ts'; const FIXTURES = join(import.meta.dirname, 'fixtures'); const NOTES_VAULT = join(FIXTURES, 'notes-vault'); @@ -15,6 +17,7 @@ describe(searchNotes, () => { filters: {}, startDir: NOTES_VAULT, home: FIXTURES, + recall: buildRecallStub({ hits: [notePath('streams.md')] }), }); const streams = result.hits.find((hit) => hit.hit.path.endsWith('streams.md')); @@ -30,11 +33,14 @@ describe(searchNotes, () => { filters: { diataxis: 'howto' }, startDir: NOTES_VAULT, home: FIXTURES, + recall: buildRecallStub({ + hits: [notePath('new-guide.md'), notePath('mid-guide.md'), notePath(join('sub', 'hooks.md'))], + }), }); - expect(result.hits.length).toBeGreaterThan(0); expect(result.hits.every((hit) => hit.note.frontmatter?.extra.diataxis === 'howto')).toBe(true); - expect(result.recalledCount).toBeGreaterThanOrEqual(result.hits.length); + expect(result.hits).toHaveLength(2); + expect(result.recalledCount).toBe(3); }); it('returns no hits but a positive recalledCount when a filter excludes every match', async () => { @@ -44,13 +50,16 @@ describe(searchNotes, () => { filters: { folder: 'zzz-nonexistent' }, startDir: NOTES_VAULT, home: FIXTURES, + recall: buildRecallStub({ hits: [notePath('streams.md')] }), }); expect(result.hits).toEqual([]); - expect(result.recalledCount).toBeGreaterThan(0); + expect(result.recalledCount).toBe(1); }); - it('sets an empty-scope diagnostic and searches nothing for an unregistered store', async () => { + it('sets an empty-scope diagnostic and recalls nothing for an unregistered store', async () => { + const recall = vi.fn(buildRecallStub({ hits: [notePath('streams.md')] })); + const result = await searchNotes({ query: 'anything', allKbs: false, @@ -58,11 +67,13 @@ describe(searchNotes, () => { filters: {}, startDir: NOTES_VAULT, home: FIXTURES, + recall, }); expect(result.hits).toEqual([]); expect(result.scopedKbs).toEqual([]); expect(result.emptyScopeDiagnostic).toMatch(/not registered/); + expect(recall).not.toHaveBeenCalled(); }); it('skips an unreadable note and surfaces a warning rather than dropping it silently', async () => { @@ -85,6 +96,7 @@ describe(searchNotes, () => { filters: {}, startDir: NOTES_VAULT, home: FIXTURES, + recall: buildRecallStub({ hits: [notePath('streams.md'), notePath('new-guide.md')] }), }); expect(result.hits.some((hit) => hit.hit.path.endsWith('streams.md'))).toBe(false); @@ -96,3 +108,8 @@ describe(searchNotes, () => { vi.resetModules(); }); }); + +/** Resolves a note's vault-relative name to its absolute path in the shared fixture vault. */ +function notePath(name: string): string { + return join(NOTES_VAULT, name); +} diff --git a/packages/agents/src/kb-search/recall.ts b/packages/agents/src/kb-search/recall.ts index 89afa515..4df72419 100644 --- a/packages/agents/src/kb-search/recall.ts +++ b/packages/agents/src/kb-search/recall.ts @@ -18,6 +18,12 @@ const execFileAsync = promisify(execFile); */ export type ProcessRunner = (command: string, args: readonly string[]) => Promise<{ stdout: string }>; +/** + * Recalls notes for a query across the in-scope KBs. The seam `searchNotes` injects, so a test of scoping, filtering, + * or a command's projection never reaches ripgrep. + */ +export type RecallFn = (input: { query: string; scopedKbs: ScopedKb[] }) => Promise; + /** Output cap for one ripgrep invocation, sized well past the match set of a large vault. */ const RIPGREP_MAX_BUFFER = 32 * 1024 * 1024; diff --git a/packages/agents/src/kb-search/search.ts b/packages/agents/src/kb-search/search.ts index 448ec415..4ba9d513 100644 --- a/packages/agents/src/kb-search/search.ts +++ b/packages/agents/src/kb-search/search.ts @@ -6,6 +6,7 @@ import type { ParsedNote } from '@codeassembly/kb/frontmatter'; import { resolveKbDir } from '@codeassembly/kb/layout'; import { extractString, parseNoteSafely } from '../kb-shared/note-helpers.ts'; +import type { RecallFn } from './recall.ts'; import { recallNotes } from './recall.ts'; import { resolveScope } from './scope.ts'; import type { RawHit, RecallFilters, ScopedKb, SearchHit, SearchResult } from './types.ts'; @@ -24,7 +25,8 @@ import type { RawHit, RecallFilters, ScopedKb, SearchHit, SearchResult } from '. * frontmatter still becomes a hit (a degraded one), so a broken note is not hidden from the projecting command. * * `home` overrides the directory the user-global `kb.yaml` is read from; it exists so tests can isolate registry - * resolution from the developer's environment. + * resolution from the developer's environment. `recall` overrides how candidate notes are recalled, defaulting to + * ripgrep; it exists so a test of scoping, filtering, or projection never spawns a process. */ export async function searchNotes(input: { query: string; @@ -33,6 +35,7 @@ export async function searchNotes(input: { filters: RecallFilters; startDir: string; home?: string; + recall?: RecallFn; }): Promise { const { kbs: inScopeKbs, @@ -54,7 +57,8 @@ export async function searchNotes(input: { }; } - const { hits: rawHits, missingKbs } = await recallNotes({ query: input.query, scopedKbs: inScopeKbs }); + const recall = input.recall ?? recallNotes; + const { hits: rawHits, missingKbs } = await recall({ query: input.query, scopedKbs: inScopeKbs }); // Scope ripgrep's raw hits to each KB's configured note set — the same `targets`/`exclude` definition `kb check` // enforces — so non-note markdown under the root and excluded paths never reach the candidate table. diff --git a/packages/agents/src/kb-search/test-utils/build-recall-stub.ts b/packages/agents/src/kb-search/test-utils/build-recall-stub.ts new file mode 100644 index 00000000..6bb7211f --- /dev/null +++ b/packages/agents/src/kb-search/test-utils/build-recall-stub.ts @@ -0,0 +1,41 @@ +// Test-only construction of the recall seam `searchNotes` accepts. A test of scoping, filtering, or a command's +// projection states which notes recall found instead of matching them for real, so it needs neither ripgrep nor a +// query that happens to hit the right fixture notes. + +import { sep } from 'node:path'; + +import type { RecallFn, RecallResult } from '../recall.ts'; +import type { RawHit, ScopedKb } from '../types.ts'; + +/** + * Builds a recall stub reporting `hits` as the notes found, attributing each to whichever in-scope KB contains it, and + * reporting the KB roots in `missing` as absent. Every query recalls the same notes: what a test states here is the + * recall outcome it wants, not a matcher to be re-derived. + * + * A hit path under no in-scope KB throws, since it can only mean the test named a note the scope never covered. + */ +export function buildRecallStub(input: { hits?: readonly string[]; missing?: readonly string[] } = {}): RecallFn { + const hitPaths = input.hits ?? []; + const missingPaths = new Set(input.missing ?? []); + + return function recallStub({ scopedKbs }): Promise { + const searched = scopedKbs.filter((kb) => !missingPaths.has(kb.path)); + return Promise.resolve({ + hits: hitPaths.map((path) => buildHit(path, searched)), + missingKbs: scopedKbs.filter((kb) => missingPaths.has(kb.path)), + }); + }; +} + +// region | Helpers + +/** Attributes one note path to the in-scope KB that contains it, matching the shape real recall reports. */ +function buildHit(path: string, searchedKbs: readonly ScopedKb[]): RawHit { + const kb = searchedKbs.find((candidate) => path === candidate.path || path.startsWith(`${candidate.path}${sep}`)); + if (kb === undefined) { + throw new Error(`recall stub was given the note "${path}", which lies under none of the in-scope KBs`); + } + return { path, kbName: kb.name, kbPath: kb.path, snippet: `snippet for ${path}` }; +} + +// endregion | Helpers From 397542399ed6fe104b235731386bb7ceeed561ef Mon Sep 17 00:00:00 2001 From: William Thorsen Date: Mon, 27 Jul 2026 15:23:27 -0700 Subject: [PATCH 3/9] agents|tests: Drive the retrieve CLI tests from a recall stub Running the retrieve CLI tests no longer requires ripgrep on PATH. Each test states the notes recall found, so what it asserts about candidate tables, filters, and diagnostics no longer depends on a query happening to match the right fixture notes. The --diataxis test recalls a reference note alongside the how-to notes, giving the filter it exercises something to exclude. --- .../kb-retrieve-events/__tests__/cli.test.ts | 55 ++++++++- .../src/kb-retrieve/__tests__/cli.test.ts | 111 +++++++++++++++--- 2 files changed, 142 insertions(+), 24 deletions(-) diff --git a/packages/agents/src/kb-retrieve-events/__tests__/cli.test.ts b/packages/agents/src/kb-retrieve-events/__tests__/cli.test.ts index 7bed1c80..95c915e3 100644 --- a/packages/agents/src/kb-retrieve-events/__tests__/cli.test.ts +++ b/packages/agents/src/kb-retrieve-events/__tests__/cli.test.ts @@ -2,6 +2,7 @@ import { join } from 'node:path'; import { describe, expect, it } from 'vitest'; +import { buildRecallStub } from '../../kb-search/test-utils/build-recall-stub.ts'; import { parseArgs, runRetrieveEvents } from '../cli.ts'; // The vault and registry fixtures live with the shared search primitive (kb-search), which owns scope and recall. @@ -58,7 +59,12 @@ describe(parseArgs, () => { describe(runRetrieveEvents, () => { it('returns an event candidate carrying its recurrence signals', async () => { - const result = await runRetrieveEvents({ argv: ['phantomwidget'], startDir: NOTES_VAULT, home: FIXTURES }); + const result = await runRetrieveEvents({ + argv: ['phantomwidget'], + startDir: NOTES_VAULT, + home: FIXTURES, + recall: buildRecallStub({ hits: [eventNote('01HZCEVENTAAAAAAAAAAAAAAAA.md')] }), + }); expect(result.candidates).toHaveLength(1); expect(result.candidates[0]).toMatchObject({ @@ -70,15 +76,25 @@ describe(runRetrieveEvents, () => { }); it('excludes assertion records, pointing the reader at assertion recall', async () => { - const result = await runRetrieveEvents({ argv: ['backpressure'], startDir: NOTES_VAULT, home: FIXTURES }); + const result = await runRetrieveEvents({ + argv: ['backpressure'], + startDir: NOTES_VAULT, + home: FIXTURES, + recall: buildRecallStub({ hits: [join(NOTES_VAULT, 'streams.md')] }), + }); - // backpressure matches only an assertion, so the event table is empty and the diagnostic routes to assertion recall. + // The only recalled note is an assertion, so the event table is empty and the diagnostic routes to assertion recall. expect(result.candidates).toEqual([]); expect(result.diagnostic).toMatch(/kb-retrieve/); }); it('reports a no-match diagnostic when nothing matches', async () => { - const result = await runRetrieveEvents({ argv: ['zzzznomatch'], startDir: NOTES_VAULT, home: FIXTURES }); + const result = await runRetrieveEvents({ + argv: ['zzzznomatch'], + startDir: NOTES_VAULT, + home: FIXTURES, + recall: buildRecallStub(), + }); expect(result.candidates).toEqual([]); expect(result.diagnostic).toBe('no notes matched the query'); @@ -89,6 +105,7 @@ describe(runRetrieveEvents, () => { argv: ['phantomwidget', '--store', 'no-such-store'], startDir: NOTES_VAULT, home: FIXTURES, + recall: buildRecallStub(), }); expect(result.candidates).toEqual([]); @@ -97,14 +114,24 @@ describe(runRetrieveEvents, () => { }); it('reports a diagnostic when the query is blank', async () => { - const result = await runRetrieveEvents({ argv: [], startDir: NOTES_VAULT, home: FIXTURES }); + const result = await runRetrieveEvents({ + argv: [], + startDir: NOTES_VAULT, + home: FIXTURES, + recall: buildRecallStub(), + }); expect(result.candidates).toEqual([]); expect(result.diagnostic).toBe('no query provided'); }); it('surfaces a declared impact on a candidate and omits it on an unrated one', async () => { - const result = await runRetrieveEvents({ argv: ['snorkleweft'], startDir: NOTES_VAULT, home: FIXTURES }); + const result = await runRetrieveEvents({ + argv: ['snorkleweft'], + startDir: NOTES_VAULT, + home: FIXTURES, + recall: buildRecallStub({ hits: snorkleweftEvents() }), + }); const byImpact = new Map(result.candidates.map((candidate) => [candidate.summary, candidate.impact])); expect(byImpact.get('A snorkleweft outage rated high')).toBe('high'); @@ -116,6 +143,7 @@ describe(runRetrieveEvents, () => { argv: ['snorkleweft', '--min-impact', 'high'], startDir: NOTES_VAULT, home: FIXTURES, + recall: buildRecallStub({ hits: snorkleweftEvents() }), }); expect(result.candidates).toHaveLength(1); @@ -127,9 +155,24 @@ describe(runRetrieveEvents, () => { argv: ['snorkleweft', '--min-impact', 'critical'], startDir: NOTES_VAULT, home: FIXTURES, + recall: buildRecallStub({ hits: snorkleweftEvents() }), }); expect(result.candidates).toEqual([]); expect(result.diagnostic).toBe('all matches were below the --min-impact threshold of critical'); }); }); + +/** Resolves an event record's filename to its absolute path in the shared fixture vault. */ +function eventNote(name: string): string { + return join(NOTES_VAULT, 'content', 'events', name); +} + +/** The fixture events mentioning `snorkleweft`: one rated high, one rated lower, one left unrated. */ +function snorkleweftEvents(): string[] { + return [ + eventNote('01HZCEVENTHGHAAAAAAAAAAAAA.md'), + eventNote('01HZCEVENTMNRAAAAAAAAAAAAA.md'), + eventNote('01HZCEVENTNRTAAAAAAAAAAAAA.md'), + ]; +} diff --git a/packages/agents/src/kb-retrieve/__tests__/cli.test.ts b/packages/agents/src/kb-retrieve/__tests__/cli.test.ts index 6a3d38ea..f8a16068 100644 --- a/packages/agents/src/kb-retrieve/__tests__/cli.test.ts +++ b/packages/agents/src/kb-retrieve/__tests__/cli.test.ts @@ -2,6 +2,7 @@ import { join } from 'node:path'; import { describe, expect, it } from 'vitest'; +import { buildRecallStub } from '../../kb-search/test-utils/build-recall-stub.ts'; import { parseArgs, runRetrieve } from '../cli.ts'; // The vault and registry fixtures live with the shared search primitive (kb-search), which owns scope and recall; the @@ -96,6 +97,7 @@ describe(runRetrieve, () => { startDir: NOTES_VAULT, now: NOW, home: FIXTURES, + recall: buildRecallStub({ hits: [join(NOTES_VAULT, 'streams.md')] }), }); expect(result.candidates.map((candidate) => candidate.title)).toContain('Working with Node.js streams'); @@ -108,9 +110,10 @@ describe(runRetrieve, () => { startDir: NOTES_VAULT, now: NOW, home: FIXTURES, + recall: buildRecallStub({ hits: [eventNote('01HZCEVENTAAAAAAAAAAAAAAAA.md')] }), }); - // phantomwidget matches only an event, so the assertion table is empty and the diagnostic routes to event recall. + // The only recalled note is an event, so the assertion table is empty and the diagnostic routes to event recall. expect(result.candidates).toEqual([]); expect(result.diagnostic).toMatch(/kb-retrieve-events/); }); @@ -121,6 +124,7 @@ describe(runRetrieve, () => { startDir: NOTES_VAULT, now: NOW, home: FIXTURES, + recall: buildRecallStub({ hits: [join(NOTES_VAULT, 'legacy-untyped.md')] }), }); expect(result.candidates).toHaveLength(1); @@ -134,10 +138,18 @@ describe(runRetrieve, () => { startDir: NOTES_VAULT, now: NOW, home: FIXTURES, + // hooks.md is diataxis: reference, so the filter has something to exclude. + recall: buildRecallStub({ + hits: [ + join(NOTES_VAULT, 'new-guide.md'), + join(NOTES_VAULT, 'mid-guide.md'), + join(NOTES_VAULT, 'sub', 'hooks.md'), + ], + }), }); expect(result.candidates.every((candidate) => candidate.diataxis === 'howto')).toBe(true); - expect(result.candidates.length).toBeGreaterThan(0); + expect(result.candidates).toHaveLength(2); }); it('reports a diagnostic and no candidates when nothing matches', async () => { @@ -146,6 +158,7 @@ describe(runRetrieve, () => { startDir: NOTES_VAULT, now: NOW, home: FIXTURES, + recall: buildRecallStub(), }); expect(result.candidates).toEqual([]); @@ -159,6 +172,7 @@ describe(runRetrieve, () => { startDir: NOTES_VAULT, now: NOW, home: FIXTURES, + recall: buildRecallStub({ hits: [join(NOTES_VAULT, 'streams.md')] }), }); expect(result.candidates).toEqual([]); @@ -167,7 +181,13 @@ describe(runRetrieve, () => { }); it('reports a diagnostic when no knowledge base is configured or discovered', async () => { - const result = await runRetrieve({ argv: ['anything'], startDir: '/', now: NOW, home: FIXTURES }); + const result = await runRetrieve({ + argv: ['anything'], + startDir: '/', + now: NOW, + home: FIXTURES, + recall: buildRecallStub(), + }); expect(result.scopedKbs).toEqual([]); expect(result.warnings).toEqual([]); @@ -180,6 +200,7 @@ describe(runRetrieve, () => { startDir: NOTES_VAULT, now: NOW, home: FIXTURES, + recall: buildRecallStub(), }); expect(result.scopedKbs).toEqual([]); @@ -187,14 +208,25 @@ describe(runRetrieve, () => { }); it('reports a diagnostic when the query is blank', async () => { - const result = await runRetrieve({ argv: ['--all-kbs'], startDir: NOTES_VAULT, now: NOW }); + const result = await runRetrieve({ + argv: ['--all-kbs'], + startDir: NOTES_VAULT, + now: NOW, + recall: buildRecallStub(), + }); expect(result.candidates).toEqual([]); expect(result.diagnostic).toBe('no query provided'); }); it('names the registry defect in warnings and diagnostic when a malformed registry is the only KB', async () => { - const result = await runRetrieve({ argv: ['anything'], startDir: MALFORMED_NO_KB, now: NOW, home: FIXTURES }); + const result = await runRetrieve({ + argv: ['anything'], + startDir: MALFORMED_NO_KB, + now: NOW, + home: FIXTURES, + recall: buildRecallStub(), + }); expect(result.candidates).toEqual([]); expect(result.scopedKbs).toEqual([]); @@ -204,7 +236,13 @@ describe(runRetrieve, () => { }); it('returns candidates while still warning about a malformed registry alongside a discovered KB', async () => { - const result = await runRetrieve({ argv: ['zarquon'], startDir: MALFORMED_REGISTRY, now: NOW, home: FIXTURES }); + const result = await runRetrieve({ + argv: ['zarquon'], + startDir: MALFORMED_REGISTRY, + now: NOW, + home: FIXTURES, + recall: buildRecallStub({ hits: [join(MALFORMED_REGISTRY, 'searchable-note.md')] }), + }); expect(result.candidates.length).toBeGreaterThan(0); expect(result.warnings).toHaveLength(1); @@ -213,33 +251,44 @@ describe(runRetrieve, () => { }); it('warns about a dead-path entry that is the only KB and excludes it from scopedKbs', async () => { - const result = await runRetrieve({ argv: ['anything'], startDir: DEAD_PATH_REGISTRY, now: NOW, home: FIXTURES }); + const ghostPath = join(DEAD_PATH_REGISTRY, '.agents', 'no-such-kb-directory'); + const result = await runRetrieve({ + argv: ['anything'], + startDir: DEAD_PATH_REGISTRY, + now: NOW, + home: FIXTURES, + recall: buildRecallStub({ missing: [ghostPath] }), + }); expect(result.candidates).toEqual([]); expect(result.scopedKbs).toEqual([]); - expect(result.warnings).toEqual([ - `registry KB "ghost-vault" path does not exist: ${join(DEAD_PATH_REGISTRY, '.agents', 'no-such-kb-directory')}`, - ]); + expect(result.warnings).toEqual([`registry KB "ghost-vault" path does not exist: ${ghostPath}`]); expect(result.diagnostic).toBe('no notes matched the query'); }); it('returns candidates while warning about a dead-path entry alongside a live KB', async () => { + const ghostPath = join(MIXED_REGISTRY, '.agents', 'no-such-kb-directory'); const result = await runRetrieve({ argv: ['backpressure', '--all-kbs'], startDir: MIXED_REGISTRY, now: NOW, home: FIXTURES, + recall: buildRecallStub({ hits: [join(NOTES_VAULT, 'streams.md')], missing: [ghostPath] }), }); expect(result.candidates.length).toBeGreaterThan(0); - expect(result.warnings).toEqual([ - `registry KB "ghost-vault" path does not exist: ${join(MIXED_REGISTRY, '.agents', 'no-such-kb-directory')}`, - ]); + expect(result.warnings).toEqual([`registry KB "ghost-vault" path does not exist: ${ghostPath}`]); expect(result.diagnostic).toBeUndefined(); }); it('keeps warnings empty when no registry is configured and a discovered KB is searched', async () => { - const result = await runRetrieve({ argv: ['backpressure'], startDir: NOTES_VAULT, now: NOW, home: FIXTURES }); + const result = await runRetrieve({ + argv: ['backpressure'], + startDir: NOTES_VAULT, + now: NOW, + home: FIXTURES, + recall: buildRecallStub({ hits: [join(NOTES_VAULT, 'streams.md')] }), + }); expect(result.candidates.length).toBeGreaterThan(0); expect(result.warnings).toEqual([]); @@ -251,6 +300,7 @@ describe(runRetrieve, () => { startDir: CUSTOM_SCHEMA_VAULT, now: NOW, home: FIXTURES, + recall: buildRecallStub({ hits: [join(CUSTOM_SCHEMA_VAULT, 'insight-note.md')] }), }); // insight-note declares recordType: insight — neither an assertion nor an event — so no retrieve command claims it. @@ -265,6 +315,7 @@ describe(runRetrieve, () => { startDir: MALFORMED_SCHEMA_VAULT, now: NOW, home: FIXTURES, + recall: buildRecallStub({ hits: [join(MALFORMED_SCHEMA_VAULT, 'plain-note.md')] }), }); expect(result.candidates.length).toBeGreaterThan(0); @@ -280,6 +331,9 @@ describe(runRetrieve, () => { startDir: MULTI_SCHEMA_REGISTRY, now: NOW, home: FIXTURES, + recall: buildRecallStub({ + hits: [join(CUSTOM_SCHEMA_VAULT, 'insight-note.md'), join(MALFORMED_SCHEMA_VAULT, 'plain-note.md')], + }), }); const insight = result.candidates.find((candidate) => candidate.path.includes('insight-note.md')); @@ -291,9 +345,21 @@ describe(runRetrieve, () => { }); it('recalls only notes inside the configured targets, skipping root and excluded markdown', async () => { - // scoped-vault stores `zephyrquux` in README.md (root), content/in-scope.md, and content/drafts/excluded.md; - // its config targets `content/**/*.md` and excludes `content/drafts/**`, so only in-scope.md is a note. - const result = await runRetrieve({ argv: ['zephyrquux'], startDir: SCOPED_VAULT, now: NOW, home: FIXTURES }); + // scoped-vault's config targets `content/**/*.md` and excludes `content/drafts/**`, so of the three recalled + // notes only in-scope.md is inside the note set. + const result = await runRetrieve({ + argv: ['zephyrquux'], + startDir: SCOPED_VAULT, + now: NOW, + home: FIXTURES, + recall: buildRecallStub({ + hits: [ + join(SCOPED_VAULT, 'README.md'), + join(SCOPED_VAULT, 'content', 'drafts', 'excluded.md'), + join(SCOPED_VAULT, 'content', 'in-scope.md'), + ], + }), + }); expect(result.candidates.map((candidate) => candidate.path.split('/').at(-1))).toEqual(['in-scope.md']); expect(result.warnings).toEqual([]); @@ -306,6 +372,9 @@ describe(runRetrieve, () => { startDir: DEFAULT_SCOPE_VAULT, now: NOW, home: FIXTURES, + recall: buildRecallStub({ + hits: [join(DEFAULT_SCOPE_VAULT, 'README.md'), join(DEFAULT_SCOPE_VAULT, 'content', 'note.md')], + }), }); expect(result.candidates.map((candidate) => candidate.path.split('/').at(-1))).toEqual(['note.md']); @@ -317,12 +386,18 @@ describe(runRetrieve, () => { startDir: MALFORMED_CONFIG_VAULT, now: NOW, home: FIXTURES, + recall: buildRecallStub({ hits: [join(MALFORMED_CONFIG_VAULT, 'content', 'note.md')] }), }); - // The content/ note still recalls under the degraded default config, and the defect surfaces as one warning. + // The content/ note still survives under the degraded default config, and the defect surfaces as one warning. expect(result.candidates.map((candidate) => candidate.path.split('/').at(-1))).toEqual(['note.md']); expect(result.warnings).toHaveLength(1); expect(result.warnings[0]).toMatch(/config invalid/); expect(result.diagnostic).toBeUndefined(); }); }); + +/** Resolves an event record's filename to its absolute path in the shared fixture vault. */ +function eventNote(name: string): string { + return join(NOTES_VAULT, 'content', 'events', name); +} From 3efea3b7a142a6035c1805aaf4eb19579a068ea6 Mon Sep 17 00:00:00 2001 From: William Thorsen Date: Mon, 27 Jul 2026 15:25:45 -0700 Subject: [PATCH 4/9] agents|tests: Take the retrieve-events smoke run off the recall path The post-build smoke run no longer requires ripgrep on PATH, so no step of the build depends on an external binary. The kb-retrieve-events pairing scopes to an unregistered store, asserting the resolver reached a scope verdict and shaped its JSON result, rather than recalling a seeded event. --- .../scripts/testing/smoke-test-utils.ts | 50 ++++++------------- 1 file changed, 14 insertions(+), 36 deletions(-) diff --git a/packages/agents/scripts/testing/smoke-test-utils.ts b/packages/agents/scripts/testing/smoke-test-utils.ts index c5d38c24..e862b8ab 100644 --- a/packages/agents/scripts/testing/smoke-test-utils.ts +++ b/packages/agents/scripts/testing/smoke-test-utils.ts @@ -183,35 +183,17 @@ export function makeKbEditSmokeTest(): SmokeTestInvocation { } /** - * Stands up an event store carrying a single seed event plus an isolated home registering it as `default_kb`, then - * returns a `SmokeTestInvocation` that recalls the event by a body term scoped to that store. Exercises the full scope → - * ripgrep recall → note-set scoping → event projection pipeline, the only path that wires the bundled search primitive - * and the event projection together. `ripgrep` (`rg`) must be on PATH for recall to find the seed event. + * Stands up an event store plus an isolated home registering it, then returns a `SmokeTestInvocation` that scopes to a + * store name the registry does not carry. Exercises the bundled resolver from home discovery through registry parse to + * the scope verdict and the JSON result shape, stopping short of recall. + * + * Recall itself is deliberately not exercised here: reaching it would put ripgrep on the critical path of every build, + * and the read → parse → project pipeline beneath it is already covered by the kb-update-events pairing. */ export function makeKbRetrieveEventsSmokeTest(): SmokeTestInvocation { const storePath = mkdtempSync(path.join(tmpdir(), 'kb-retrieve-events-store-')); mkdirSync(resolveKbDir(storePath), { recursive: true }); - mkdirSync(resolveEventsDir(storePath), { recursive: true }); - writeFileSync( - resolveEventPath({ storePath, id: 'smoke-event' }), - [ - '---', - 'recordType: event', - 'id: smoke-event', - 'captured-at: 2026-06-18T09:41:02Z', - 'session: smoke', - 'cwd: /tmp/smoke', - 'summary: Smoke retrieve event', - 'repo: owner/repo-smoke', - '---', - '', - 'A smoke note mentioning retrievesmokequux.', - '', - ].join('\n'), - 'utf8', - ); - const home = mkdtempSync(path.join(tmpdir(), 'kb-retrieve-events-home-')); mkdirSync(path.join(home, '.agents'), { recursive: true }); writeFileSync( @@ -221,7 +203,7 @@ export function makeKbRetrieveEventsSmokeTest(): SmokeTestInvocation { ); return { - args: ['retrievesmokequux', '--store', 'codeassembly'], + args: ['retrievesmokequux', '--store', 'no-such-store'], env: { ...process.env, HOME: home }, assertResult: assertKbRetrieveEventsSmokeResult, }; @@ -466,23 +448,19 @@ function assertKbEditSmokeResult(result: unknown): void { } } -/** Assert the kb-retrieve-events smoke recalled the seed event and projected it with its summary and capture timestamp. */ +/** Assert the kb-retrieve-events smoke resolved the registry and reported the requested store as unregistered. */ function assertKbRetrieveEventsSmokeResult(result: unknown): void { if (!isRecord(result)) { throw new TypeError('expected object result from kb-retrieve-events'); } - if (!Array.isArray(result.candidates) || result.candidates.length === 0) { - throw new Error(`expected at least one event candidate, got ${JSON.stringify(result)}`); - } - const candidate: unknown = result.candidates[0]; - if (!isRecord(candidate)) { - throw new TypeError('expected a candidate object'); + if (!Array.isArray(result.candidates) || result.candidates.length > 0) { + throw new Error(`expected an empty candidate table, got ${JSON.stringify(result)}`); } - if (candidate.summary !== 'Smoke retrieve event') { - throw new Error(`expected summary 'Smoke retrieve event', got ${JSON.stringify(candidate.summary)}`); + if (!Array.isArray(result.scopedKbs) || result.scopedKbs.length > 0) { + throw new Error(`expected an empty scope, got ${JSON.stringify(result.scopedKbs)}`); } - if (typeof candidate.capturedAt !== 'string' || !candidate.capturedAt.includes('2026-06-18')) { - throw new Error(`expected an ISO capturedAt, got ${JSON.stringify(candidate.capturedAt)}`); + if (typeof result.diagnostic !== 'string' || !result.diagnostic.includes('is not registered in kb.yaml')) { + throw new Error(`expected an unregistered-store diagnostic, got ${JSON.stringify(result.diagnostic)}`); } } From 6e7c9b7190cc11f4935035b05d8a01d1dac3ce72 Mon Sep 17 00:00:00 2001 From: William Thorsen Date: Mon, 27 Jul 2026 15:29:30 -0700 Subject: [PATCH 5/9] agents|tests: Clear strict-lint findings from the recall test helpers The recall stub builds its missing-path set from the optional argument directly, and the search test names a nested fixture path without composing two joins. --- packages/agents/src/kb-search/__tests__/search.test.ts | 2 +- packages/agents/src/kb-search/test-utils/build-recall-stub.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/agents/src/kb-search/__tests__/search.test.ts b/packages/agents/src/kb-search/__tests__/search.test.ts index 4fd5fad6..516e6c78 100644 --- a/packages/agents/src/kb-search/__tests__/search.test.ts +++ b/packages/agents/src/kb-search/__tests__/search.test.ts @@ -34,7 +34,7 @@ describe(searchNotes, () => { startDir: NOTES_VAULT, home: FIXTURES, recall: buildRecallStub({ - hits: [notePath('new-guide.md'), notePath('mid-guide.md'), notePath(join('sub', 'hooks.md'))], + hits: [notePath('new-guide.md'), notePath('mid-guide.md'), notePath('sub/hooks.md')], }), }); diff --git a/packages/agents/src/kb-search/test-utils/build-recall-stub.ts b/packages/agents/src/kb-search/test-utils/build-recall-stub.ts index 6bb7211f..14d2c87e 100644 --- a/packages/agents/src/kb-search/test-utils/build-recall-stub.ts +++ b/packages/agents/src/kb-search/test-utils/build-recall-stub.ts @@ -16,7 +16,7 @@ import type { RawHit, ScopedKb } from '../types.ts'; */ export function buildRecallStub(input: { hits?: readonly string[]; missing?: readonly string[] } = {}): RecallFn { const hitPaths = input.hits ?? []; - const missingPaths = new Set(input.missing ?? []); + const missingPaths = new Set(input.missing); return function recallStub({ scopedKbs }): Promise { const searched = scopedKbs.filter((kb) => !missingPaths.has(kb.path)); From 6ce41811aae0b00053c3fbfc582574720727fd86 Mon Sep 17 00:00:00 2001 From: William Thorsen Date: Mon, 27 Jul 2026 16:03:55 -0700 Subject: [PATCH 6/9] agents|refactor: Cut change-history narration from the recall comments Comments on the recall seam, its stub, and the smoke pairing state the contract and the non-obvious invariants alone, dropping the rationale for introducing them and the description of what the tests did beforehand. --- packages/agents/scripts/testing/smoke-test-utils.ts | 3 +-- packages/agents/src/kb-search/__tests__/recall.test.ts | 5 +---- packages/agents/src/kb-search/recall.ts | 9 ++------- .../agents/src/kb-search/test-utils/build-recall-stub.ts | 7 +------ 4 files changed, 5 insertions(+), 19 deletions(-) diff --git a/packages/agents/scripts/testing/smoke-test-utils.ts b/packages/agents/scripts/testing/smoke-test-utils.ts index e862b8ab..13d9c349 100644 --- a/packages/agents/scripts/testing/smoke-test-utils.ts +++ b/packages/agents/scripts/testing/smoke-test-utils.ts @@ -187,8 +187,7 @@ export function makeKbEditSmokeTest(): SmokeTestInvocation { * store name the registry does not carry. Exercises the bundled resolver from home discovery through registry parse to * the scope verdict and the JSON result shape, stopping short of recall. * - * Recall itself is deliberately not exercised here: reaching it would put ripgrep on the critical path of every build, - * and the read → parse → project pipeline beneath it is already covered by the kb-update-events pairing. + * Keep the invocation off the recall path: recalling here would put ripgrep on the critical path of every build. */ export function makeKbRetrieveEventsSmokeTest(): SmokeTestInvocation { const storePath = mkdtempSync(path.join(tmpdir(), 'kb-retrieve-events-store-')); diff --git a/packages/agents/src/kb-search/__tests__/recall.test.ts b/packages/agents/src/kb-search/__tests__/recall.test.ts index eb539c8d..0f4c8606 100644 --- a/packages/agents/src/kb-search/__tests__/recall.test.ts +++ b/packages/agents/src/kb-search/__tests__/recall.test.ts @@ -110,8 +110,6 @@ describe(recallNotes, () => { }); it('throws when ripgrep reports matches but none of its output can be parsed', async () => { - // An unreadable --json shape must not degrade to "no matches", which would report an empty vault to the reader - // while recall is in fact broken. const runner = vi.fn().mockResolvedValue({ stdout: '{"type":"match","data":{"unexpected":true}}' }); await expect(recallNotes({ query: 'backpressure', scopedKbs: notesVaultScope, runner })).rejects.toThrow( @@ -148,8 +146,7 @@ describe(recallNotes, () => { describe(parseRipgrepOutput, () => { it('reads a note path from the structured field rather than from the line text', () => { - // Digit runs in a path such as 2026-06-01/2026-05-01-meeting-notes.md are what a line-oriented parser misread as - // ripgrep's line-number field. The --json events carry the path in its own field, so the ambiguity cannot arise. + // The date-patterned segments carry digit runs that resemble ripgrep's line-number field. const stream = buildRipgrepOutput([['/vault/2026-06-01/2026-05-01-meeting-notes.md', 'grumbletwist']]); expect(parseRipgrepOutput(stream)).toEqual([ diff --git a/packages/agents/src/kb-search/recall.ts b/packages/agents/src/kb-search/recall.ts index 4df72419..d1c622e8 100644 --- a/packages/agents/src/kb-search/recall.ts +++ b/packages/agents/src/kb-search/recall.ts @@ -13,15 +13,11 @@ const execFileAsync = promisify(execFile); /** * Runs a process and resolves its captured stdout, rejecting with an error carrying `code` on a non-zero exit or a - * failed spawn. Recall reaches ripgrep only through this seam, so tests assert the arguments it is called with and - * drive the exit-code and missing-binary branches without spawning anything. + * failed spawn. The only path by which recall reaches ripgrep, so a caller can substitute one and spawn nothing. */ export type ProcessRunner = (command: string, args: readonly string[]) => Promise<{ stdout: string }>; -/** - * Recalls notes for a query across the in-scope KBs. The seam `searchNotes` injects, so a test of scoping, filtering, - * or a command's projection never reaches ripgrep. - */ +/** Recalls notes for a query across the in-scope KBs. The seam `searchNotes` injects, so a caller can substitute one. */ export type RecallFn = (input: { query: string; scopedKbs: ScopedKb[] }) => Promise; /** Output cap for one ripgrep invocation, sized well past the match set of a large vault. */ @@ -247,7 +243,6 @@ async function searchKb(input: { kb: ScopedKb; terms: string[]; runner: ProcessR // ripgrep exits 1 when nothing matched, which `runRipgrep` maps to an empty string, so output here means it found // something. Parsing none of it therefore means the `--json` event shape no longer matches what this module reads. - // Reporting that as "no matches" would hide a broken recall behind an ordinary-looking empty result. if (stdout.trim() !== '' && matches.length === 0) { throw new Error( `ripgrep reported matches in ${input.kb.path} but none of its output could be parsed; its --json event format may have changed`, diff --git a/packages/agents/src/kb-search/test-utils/build-recall-stub.ts b/packages/agents/src/kb-search/test-utils/build-recall-stub.ts index 14d2c87e..e58b4a02 100644 --- a/packages/agents/src/kb-search/test-utils/build-recall-stub.ts +++ b/packages/agents/src/kb-search/test-utils/build-recall-stub.ts @@ -1,7 +1,3 @@ -// Test-only construction of the recall seam `searchNotes` accepts. A test of scoping, filtering, or a command's -// projection states which notes recall found instead of matching them for real, so it needs neither ripgrep nor a -// query that happens to hit the right fixture notes. - import { sep } from 'node:path'; import type { RecallFn, RecallResult } from '../recall.ts'; @@ -9,8 +5,7 @@ import type { RawHit, ScopedKb } from '../types.ts'; /** * Builds a recall stub reporting `hits` as the notes found, attributing each to whichever in-scope KB contains it, and - * reporting the KB roots in `missing` as absent. Every query recalls the same notes: what a test states here is the - * recall outcome it wants, not a matcher to be re-derived. + * reporting the KB roots in `missing` as absent. The query is ignored: every call recalls the same notes. * * A hit path under no in-scope KB throws, since it can only mean the test named a note the scope never covered. */ From aaa3db76f5c92781c231445404f6f58ec19d730f Mon Sep 17 00:00:00 2001 From: William Thorsen Date: Mon, 27 Jul 2026 16:50:22 -0700 Subject: [PATCH 7/9] agents|refactor: Map parsed ripgrep entries straight to recall hits `parseRipgrepOutput` returns at most one entry per note path, so `searchKb` builds its hits with a direct map rather than a keyed map whose duplicate check can never fire. --- packages/agents/src/kb-search/recall.ts | 21 +++++++-------------- 1 file changed, 7 insertions(+), 14 deletions(-) diff --git a/packages/agents/src/kb-search/recall.ts b/packages/agents/src/kb-search/recall.ts index d1c622e8..55f55364 100644 --- a/packages/agents/src/kb-search/recall.ts +++ b/packages/agents/src/kb-search/recall.ts @@ -235,7 +235,7 @@ async function runRipgrepProcess(command: string, args: readonly string[]): Prom return execFileAsync(command, [...args], { maxBuffer: RIPGREP_MAX_BUFFER }); } -/** Runs a single ripgrep invocation across one KB and collect its hits, de-duplicated by note path. */ +/** Runs a single ripgrep invocation across one KB and attributes each parsed entry to it. */ async function searchKb(input: { kb: ScopedKb; terms: string[]; runner: ProcessRunner }): Promise { const pattern = input.terms.map(escapeRegExp).join('|'); const stdout = await runRipgrep({ pattern, searchDir: input.kb.path, runner: input.runner }); @@ -249,19 +249,12 @@ async function searchKb(input: { kb: ScopedKb; terms: string[]; runner: ProcessR ); } - const byPath = new Map(); - for (const match of matches) { - if (byPath.has(match.path)) { - continue; - } - byPath.set(match.path, { - path: match.path, - kbName: input.kb.name, - kbPath: input.kb.path, - snippet: match.snippet, - }); - } - return [...byPath.values()]; + return matches.map((match) => ({ + path: match.path, + kbName: input.kb.name, + kbPath: input.kb.path, + snippet: match.snippet, + })); } /** Splits a query string into lowercase search terms, dropping empties. */ From 48365aa76054035972ec115d06741a724542b428 Mon Sep 17 00:00:00 2001 From: William Thorsen Date: Mon, 27 Jul 2026 16:50:28 -0700 Subject: [PATCH 8/9] agents|tests: Assert exact candidate counts in the retrieve CLI tests Each of these tests declares the notes recall found, so the surviving candidate count is exactly one. The assertions state that count rather than a lower bound. --- packages/agents/src/kb-retrieve/__tests__/cli.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/agents/src/kb-retrieve/__tests__/cli.test.ts b/packages/agents/src/kb-retrieve/__tests__/cli.test.ts index f8a16068..74d2ca7f 100644 --- a/packages/agents/src/kb-retrieve/__tests__/cli.test.ts +++ b/packages/agents/src/kb-retrieve/__tests__/cli.test.ts @@ -244,7 +244,7 @@ describe(runRetrieve, () => { recall: buildRecallStub({ hits: [join(MALFORMED_REGISTRY, 'searchable-note.md')] }), }); - expect(result.candidates.length).toBeGreaterThan(0); + expect(result.candidates).toHaveLength(1); expect(result.warnings).toHaveLength(1); expect(result.warnings[0]).toMatch(/^registry invalid: /); expect(result.diagnostic).toBeUndefined(); @@ -276,7 +276,7 @@ describe(runRetrieve, () => { recall: buildRecallStub({ hits: [join(NOTES_VAULT, 'streams.md')], missing: [ghostPath] }), }); - expect(result.candidates.length).toBeGreaterThan(0); + expect(result.candidates).toHaveLength(1); expect(result.warnings).toEqual([`registry KB "ghost-vault" path does not exist: ${ghostPath}`]); expect(result.diagnostic).toBeUndefined(); }); @@ -290,7 +290,7 @@ describe(runRetrieve, () => { recall: buildRecallStub({ hits: [join(NOTES_VAULT, 'streams.md')] }), }); - expect(result.candidates.length).toBeGreaterThan(0); + expect(result.candidates).toHaveLength(1); expect(result.warnings).toEqual([]); }); @@ -318,7 +318,7 @@ describe(runRetrieve, () => { recall: buildRecallStub({ hits: [join(MALFORMED_SCHEMA_VAULT, 'plain-note.md')] }), }); - expect(result.candidates.length).toBeGreaterThan(0); + expect(result.candidates).toHaveLength(1); expect(result.warnings).toEqual([]); // Ranking is unaffected: the assertion note still ranks by freshness (2026-04-20 to 2026-05-01). expect(result.candidates[0]?.lastVerifiedAgeDays).toBe(11); From 567594a73d7c3ba882c7e12d5c333eab5c8d5a36 Mon Sep 17 00:00:00 2001 From: William Thorsen Date: Mon, 27 Jul 2026 16:50:34 -0700 Subject: [PATCH 9/9] agents|tests: Drop the fixture notes orphaned by the recall test rewrite `multi-match.md`, `2026-05-01-meeting-notes.md`, `2024-archive/legacy-runbook.md`, and `2026-06-01/daily-log.md` each carried a unique term for a recall test that matched it through the real binary. Those tests now assert the ripgrep argument list and parse synthetic events, so no test reads any of the four notes. --- .../notes-vault/2024-archive/legacy-runbook.md | 10 ---------- .../notes-vault/2026-05-01-meeting-notes.md | 10 ---------- .../fixtures/notes-vault/2026-06-01/daily-log.md | 10 ---------- .../__tests__/fixtures/notes-vault/multi-match.md | 14 -------------- 4 files changed, 44 deletions(-) delete mode 100644 packages/agents/src/kb-search/__tests__/fixtures/notes-vault/2024-archive/legacy-runbook.md delete mode 100644 packages/agents/src/kb-search/__tests__/fixtures/notes-vault/2026-05-01-meeting-notes.md delete mode 100644 packages/agents/src/kb-search/__tests__/fixtures/notes-vault/2026-06-01/daily-log.md delete mode 100644 packages/agents/src/kb-search/__tests__/fixtures/notes-vault/multi-match.md diff --git a/packages/agents/src/kb-search/__tests__/fixtures/notes-vault/2024-archive/legacy-runbook.md b/packages/agents/src/kb-search/__tests__/fixtures/notes-vault/2024-archive/legacy-runbook.md deleted file mode 100644 index d5760ab8..00000000 --- a/packages/agents/src/kb-search/__tests__/fixtures/notes-vault/2024-archive/legacy-runbook.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -title: Legacy archival runbook -recordType: assertion -diataxis: howto -created: 2024-03-01T09:17:05Z -updated: 2024-03-01T15:58:33Z -tags: [archive] ---- - -A note about quobble retention stored under a digit-prefixed directory. diff --git a/packages/agents/src/kb-search/__tests__/fixtures/notes-vault/2026-05-01-meeting-notes.md b/packages/agents/src/kb-search/__tests__/fixtures/notes-vault/2026-05-01-meeting-notes.md deleted file mode 100644 index 4b04fc4f..00000000 --- a/packages/agents/src/kb-search/__tests__/fixtures/notes-vault/2026-05-01-meeting-notes.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -title: Meeting notes -recordType: assertion -diataxis: note -created: 2026-05-01T07:46:29Z -updated: 2026-05-01T13:41:50Z -tags: [meeting] ---- - -A note about flummox stored in a file with a date-patterned name. diff --git a/packages/agents/src/kb-search/__tests__/fixtures/notes-vault/2026-06-01/daily-log.md b/packages/agents/src/kb-search/__tests__/fixtures/notes-vault/2026-06-01/daily-log.md deleted file mode 100644 index c181bb2a..00000000 --- a/packages/agents/src/kb-search/__tests__/fixtures/notes-vault/2026-06-01/daily-log.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -title: Daily log -recordType: assertion -diataxis: note -created: 2026-06-01T10:34:12Z -updated: 2026-06-01T17:22:08Z -tags: [journal] ---- - -A note about grumbletwist stored under a date-patterned directory. diff --git a/packages/agents/src/kb-search/__tests__/fixtures/notes-vault/multi-match.md b/packages/agents/src/kb-search/__tests__/fixtures/notes-vault/multi-match.md deleted file mode 100644 index b0c0c25a..00000000 --- a/packages/agents/src/kb-search/__tests__/fixtures/notes-vault/multi-match.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -title: Multi-match snippet fixture -recordType: assertion -diataxis: howto -created: 2026-03-01T09:05:51Z -updated: 2026-03-01T16:13:47Z -tags: [test] ---- - -The first thunderfish appears near the top of the note. - -Some intervening content that does not match the query term. - -A second thunderfish appears further down so ripgrep emits more than three events for this file.