From a0539b5f06415552ec373e278e861e20060a6df3 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Mon, 11 May 2026 18:58:14 -0700 Subject: [PATCH] fix(learnings): use token-OR matching in gstack-learnings-search --query Split the query on whitespace into tokens; a learning matches if ANY token appears as a substring in ANY of key/insight/files. Previously the whole query was a single substring, so multi-word queries like "debug investigation" only matched learnings whose insight contained that exact contiguous phrase, which is usually nothing. Whitespace-only query falls through to no-query (matches today's no-flag behavior). Single-word queries behave exactly as before. Adds test/gstack-learnings-search.test.ts: 3 assertions covering multi-token, single-token, and no-query backwards compat. Co-Authored-By: Claude Opus 4.7 --- bin/gstack-learnings-search | 14 +++---- test/gstack-learnings-search.test.ts | 60 ++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 7 deletions(-) create mode 100644 test/gstack-learnings-search.test.ts diff --git a/bin/gstack-learnings-search b/bin/gstack-learnings-search index 3b39e4626..95825635a 100755 --- a/bin/gstack-learnings-search +++ b/bin/gstack-learnings-search @@ -48,7 +48,8 @@ cat "${FILES[@]}" 2>/dev/null | GSTACK_SEARCH_TYPE="$TYPE" GSTACK_SEARCH_QUERY=" const lines = (await Bun.stdin.text()).trim().split('\n').filter(Boolean); const now = Date.now(); const type = process.env.GSTACK_SEARCH_TYPE || ''; -const query = (process.env.GSTACK_SEARCH_QUERY || '').toLowerCase(); +const queryRaw = (process.env.GSTACK_SEARCH_QUERY || '').toLowerCase(); +const queryTokens = queryRaw.split(/\s+/).filter(Boolean); const limit = parseInt(process.env.GSTACK_SEARCH_LIMIT || '10', 10); const slug = process.env.GSTACK_SEARCH_SLUG || ''; @@ -94,12 +95,11 @@ let results = Array.from(seen.values()); // Filter by type if (type) results = results.filter(e => e.type === type); -// Filter by query -if (query) results = results.filter(e => - (e.key || '').toLowerCase().includes(query) || - (e.insight || '').toLowerCase().includes(query) || - (e.files || []).some(f => f.toLowerCase().includes(query)) -); +// Filter by query (token-OR: match if ANY whitespace-split token appears in ANY haystack) +if (queryTokens.length > 0) results = results.filter(e => { + const haystacks = [(e.key || '').toLowerCase(), (e.insight || '').toLowerCase(), ...(e.files || []).map(f => f.toLowerCase())]; + return queryTokens.some(tok => haystacks.some(h => h.includes(tok))); +}); // Sort by effective confidence desc, then recency results.sort((a, b) => { diff --git a/test/gstack-learnings-search.test.ts b/test/gstack-learnings-search.test.ts new file mode 100644 index 000000000..7218d60f1 --- /dev/null +++ b/test/gstack-learnings-search.test.ts @@ -0,0 +1,60 @@ +import { describe, test, expect, beforeAll, afterAll } from 'bun:test'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { execFileSync } from 'child_process'; + +const ROOT = path.resolve(import.meta.dir, '..'); +const BIN = path.join(ROOT, 'bin', 'gstack-learnings-search'); + +const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-search-test-')); +const tmpCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-search-cwd-')); +// gstack-slug derives slug from git remote (none here) → falls back to basename of cwd. +const slug = path.basename(tmpCwd).replace(/[^a-zA-Z0-9._-]/g, ''); +const projDir = path.join(tmpHome, 'projects', slug); + +function run(args: string[]): string { + return execFileSync(BIN, args, { + env: { ...process.env, GSTACK_HOME: tmpHome }, + cwd: tmpCwd, + encoding: 'utf-8', + }); +} + +beforeAll(() => { + fs.mkdirSync(projDir, { recursive: true }); + const entries = [ + { ts: '2026-05-01T00:00:00Z', skill: 'test', type: 'pattern', key: 'foo-pattern', insight: 'A foo-related insight', confidence: 8, source: 'observed', files: [] }, + { ts: '2026-05-02T00:00:00Z', skill: 'test', type: 'pitfall', key: 'bar-pitfall', insight: 'A bar-related insight', confidence: 8, source: 'observed', files: [] }, + { ts: '2026-05-03T00:00:00Z', skill: 'test', type: 'pattern', key: 'baz-pattern', insight: 'A baz-related insight', confidence: 8, source: 'observed', files: [] }, + ]; + fs.writeFileSync(path.join(projDir, 'learnings.jsonl'), entries.map(e => JSON.stringify(e)).join('\n') + '\n'); +}); + +afterAll(() => { + fs.rmSync(tmpHome, { recursive: true, force: true }); + fs.rmSync(tmpCwd, { recursive: true, force: true }); +}); + +describe('gstack-learnings-search token-OR query semantics', () => { + test('multi-token query returns entries matching ANY token', () => { + const out = run(['--query', 'foo bar']); + expect(out).toContain('foo-pattern'); + expect(out).toContain('bar-pitfall'); + expect(out).not.toContain('baz-pattern'); + }); + + test('single-token query returns only entries matching that token', () => { + const out = run(['--query', 'foo']); + expect(out).toContain('foo-pattern'); + expect(out).not.toContain('bar-pitfall'); + expect(out).not.toContain('baz-pattern'); + }); + + test('no --query flag returns all entries (backwards-compat)', () => { + const out = run(['--limit', '10']); + expect(out).toContain('foo-pattern'); + expect(out).toContain('bar-pitfall'); + expect(out).toContain('baz-pattern'); + }); +});