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 <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-05-11 18:58:14 -07:00
parent d21ba06b5a
commit a0539b5f06
2 changed files with 67 additions and 7 deletions

View File

@@ -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) => {

View File

@@ -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');
});
});