diff --git a/bin/gstack-learnings-search b/bin/gstack-learnings-search index 888d61cbf..2557cb47c 100755 --- a/bin/gstack-learnings-search +++ b/bin/gstack-learnings-search @@ -128,25 +128,37 @@ let results = Array.from(seen.values()); // Filter by type if (type) results = results.filter(e => e.type === type); -// Filter by query (token-OR: match if ANY whitespace-split token appears in ANY haystack) +// Filter by query (token-OR: match if ANY whitespace-split token appears in ANY +// haystack) and count how many distinct tokens each entry matched (#2762). The +// count lives in a local map, never on the entry: a stored field from disk +// must not become a sort key. +const tokenHits = new Map(); 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))); + const haystacks = [String(e.key || '').toLowerCase(), String(e.insight || '').toLowerCase(), ...(Array.isArray(e.files) ? e.files : []).map(f => String(f).toLowerCase())]; + const hits = [...new Set(queryTokens)].filter(tok => haystacks.some(h => h.includes(tok))).length; + tokenHits.set(e, hits); + return hits > 0; }); -// Sort by effective confidence desc, then recency +// Sort by matched tokens desc, then effective confidence desc, then recency. +// Ranking on confidence alone let high-confidence single-token matches push an +// entry matching every token past the limit. results.sort((a, b) => { + const ah = tokenHits.get(a) || 0, bh = tokenHits.get(b) || 0; + if (bh !== ah) return bh - ah; if (b._effectiveConfidence !== a._effectiveConfidence) return b._effectiveConfidence - a._effectiveConfidence; return new Date(b.ts).getTime() - new Date(a.ts).getTime(); }); -// Limit +// Limit, and say when a query matched more than fit. +const totalMatched = results.length; results = results.slice(0, limit); if (results.length === 0) process.exit(0); -// Format output -const byType = {}; +// Format output. A null-prototype map: a row typed 'constructor' or +// '__proto__' must not find an inherited property and blank the whole run. +const byType = Object.create(null); for (const e of results) { const t = e.type || 'unknown'; if (!byType[t]) byType[t] = []; @@ -155,7 +167,10 @@ for (const e of results) { // Summary line const counts = Object.entries(byType).map(([t, arr]) => arr.length + ' ' + t + (arr.length > 1 ? 's' : '')); -console.log('LEARNINGS: ' + results.length + ' loaded (' + counts.join(', ') + ')'); +const moreNotice = queryTokens.length > 0 && totalMatched > results.length + ? ' -- ' + (totalMatched - results.length) + ' more matched, raise --limit to see them' + : ''; +console.log('LEARNINGS: ' + results.length + ' loaded (' + counts.join(', ') + ')' + moreNotice); console.log(''); for (const [t, arr] of Object.entries(byType)) { diff --git a/test/gstack-learnings-search.test.ts b/test/gstack-learnings-search.test.ts index 1d11514b2..bf628787a 100644 --- a/test/gstack-learnings-search.test.ts +++ b/test/gstack-learnings-search.test.ts @@ -141,3 +141,82 @@ describe('B5: query scripts never report a failed read as an empty result', () = expect(r.stdout).toBe(''); }); }); + +// B6 (#2762): a more specific query dropped the exact match. The token-OR +// filter recalled every entry sharing any token, the sort used confidence +// alone, and the default --limit cut silently. Vectors adapted from #2799 +// (by y$un_); ranking code from #2796 (by loulanyue). +describe('B6: rank by matched tokens and disclose truncation', () => { + const rankCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-search-rank-cwd-')); + const rankDir = path.join(tmpHome, 'projects', path.basename(rankCwd).replace(/[^a-zA-Z0-9._-]/g, '')); + const badCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-search-bad-cwd-')); + const badDir = path.join(tmpHome, 'projects', path.basename(badCwd).replace(/[^a-zA-Z0-9._-]/g, '')); + const TARGET = 'verify-preflight-project-line-before-trusting-report'; + const DECOYS = ['guideline', 'pipeline', 'deadline', 'headline', 'baseline', 'timeline', + 'outline', 'airline', 'lifeline', 'sideline', 'streamline', 'underline']; + // user-stated rows do not decay, so ranks never drift with the wall clock. + const row = (over: Record) => ({ ts: '2026-05-01T00:00:00Z', skill: 'test', type: 'pattern', confidence: 8, source: 'user-stated', trusted: false, files: [], ...over }); + const write = (dir: string, rows: object[]) => { + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'learnings.jsonl'), rows.map(r => JSON.stringify(r)).join('\n') + '\n'); + }; + write(rankDir, [ + row({ key: TARGET, insight: 'Check the project line in the preflight report before trusting it' }), + ...DECOYS.map((w, i) => row({ ts: '2026-05-' + String(4 + i).padStart(2, '0') + 'T00:00:00Z', key: 'decoy-' + w + '-rule', insight: 'A ' + w + ' related insight', confidence: 10 })), + row({ key: 'tiebreak-alpha-beta-high', insight: 'alpha beta both present', confidence: 9 }), + row({ ts: '2026-06-01T00:00:00Z', key: 'tiebreak-alpha-beta-low', insight: 'alpha beta both present', confidence: 5 }), + row({ key: 'tiebreak-alpha-solo', insight: 'alpha only here', confidence: 10 }), + row({ key: 'recency-gamma-delta-older', insight: 'gamma delta pair', confidence: 7 }), + row({ ts: '2026-06-01T00:00:00Z', key: 'recency-gamma-delta-newer', insight: 'gamma delta pair', confidence: 7 }), + row({ ts: '2020-01-01T00:00:00Z', key: 'planted-token-hits', insight: 'isolated poison row', confidence: 1, _tokenHits: 9999 }), + ]); + afterAll(() => { + fs.rmSync(rankCwd, { recursive: true, force: true }); + fs.rmSync(badCwd, { recursive: true, force: true }); + }); + const runIn = (cwd: string, args: string[]) => spawnSync('bash', [BIN, ...args], { + timeout: 30_000, env: { ...process.env, GSTACK_HOME: tmpHome }, cwd, encoding: 'utf-8', + }); + const keys = (out: string) => out.split('\n').map(l => /^- \[([^\]]+)\]/.exec(l)).filter((m): m is RegExpExecArray => m !== null).map(m => m[1]); + + test('an entry matching every query token survives the default limit, for every refinement', () => { + for (const q of ['preflight', 'preflight project', 'preflight project line']) { + expect(keys(runIn(rankCwd, ['--query', q]).stdout)).toContain(TARGET); + } + }); + + test('a 3-of-3 match outranks twelve higher-confidence 1-of-3 matches', () => { + expect(keys(runIn(rankCwd, ['--query', 'preflight project line']).stdout)[0]).toBe(TARGET); + }); + + test('token hits outrank confidence; confidence then recency break ties', () => { + expect(keys(runIn(rankCwd, ['--query', 'alpha beta']).stdout)).toEqual(['tiebreak-alpha-beta-high', 'tiebreak-alpha-beta-low', 'tiebreak-alpha-solo']); + expect(keys(runIn(rankCwd, ['--query', 'gamma delta']).stdout)).toEqual(['recency-gamma-delta-newer', 'recency-gamma-delta-older']); + }); + + test('a truncated query says how many more matched, on stdout with exit 0', () => { + const r = runIn(rankCwd, ['--query', 'preflight project line']); + expect(r.status).toBe(0); + expect(r.stdout).toContain('LEARNINGS: 10 loaded'); + expect(r.stdout).toContain('3 more matched, raise --limit to see them'); + expect(runIn(rankCwd, ['--query', 'gamma delta']).stdout).not.toContain('more matched'); + expect(runIn(rankCwd, ['--limit', '3']).stdout).not.toContain('more matched'); + }); + + test('a stored internal field cannot hijack the no-query ranking', () => { + const ranked = keys(runIn(rankCwd, ['--limit', '3']).stdout); + expect(ranked).toHaveLength(3); + expect(ranked).not.toContain('planted-token-hits'); + }); + + for (const poison of ['constructor', 'toString', 'valueOf', 'hasOwnProperty', '__proto__']) { + test(`a row typed "${poison}" cannot blank the whole store`, () => { + write(badDir, [ + ...[0, 1, 2].map(i => row({ ts: '2026-05-0' + (i + 1) + 'T00:00:00Z', key: 'healthy-' + i, insight: 'alpha only insight ' + i, confidence: 10 })), + row({ key: 'poison-row', type: poison, insight: 'alpha beta both here', confidence: 1 }), + ]); + const got = keys(runIn(badCwd, ['--query', 'alpha beta']).stdout); + for (const k of ['healthy-0', 'healthy-1', 'healthy-2']) expect(got).toContain(k); + }); + } +});