diff --git a/bin/gstack-learnings-search b/bin/gstack-learnings-search index d7038e8218..d890c5fd02 100755 --- a/bin/gstack-learnings-search +++ b/bin/gstack-learnings-search @@ -66,6 +66,50 @@ const now = Date.now(); const type = process.env.GSTACK_SEARCH_TYPE || ''; const queryRaw = (process.env.GSTACK_SEARCH_QUERY || '').toLowerCase(); const queryTokens = queryRaw.split(/\s+/).filter(Boolean); +// #2762: score over DISTINCT tokens. Repeating a word must not let an entry +// matching one concept outrank an entry matching several. Deduping cannot change +// the filter's result, so this is a ranking-only concern. +const scoreTokens = Array.from(new Set(queryTokens)); + +// #2762: word-boundary containment, used for RELEVANCE only (never for recall). +// Hand-rolled rather than a RegExp because building one from caller input needs +// the standard escape idiom, and that idiom requires a literal dollar sign -- +// which cannot appear in this block, since the whole thing is a double-quoted +// bash string. (Backslash escapes are fine here and this file already uses +// them; the dollar sign is the constraint.) Hyphens, dots and slashes are all +// separators, so hyphenated keys and file paths yield their individual words. +function isWordChar(c) { + // Case-folding identifies letters without a character-class escape: a letter + // has distinct cases, so accented Latin and Cyrillic count as word characters + // and 'caf' stops matching inside 'cafe' with an accent. Scripts without case + // (CJK) stay separators, which is what keeps an embedded Latin term findable + // in text that has no spaces around it. + return (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') + || c.toLowerCase() !== c.toUpperCase(); +} +// #2762: a query is written in the base form; prose is written in whatever form +// the sentence needs. Scoring only the exact form makes a real answer invisible to +// the query it answers, so a token also scores against its regular inflections. +// The list holds only suffixes that are true suffixes OF THE BASE FORM: 'flaky' is +// not flake+y and 'policies' is not policy+ies, so reaching those would take stem +// rewriting, and adding them as bare suffixes would widen the false-positive +// surface while matching neither. Irregulars are out of scope for the same reason. +const INFLECTIONS = ['', 's', 'es', 'd', 'ed', 'ing']; +function hasWholeWord(hay, tok) { + let i = hay.indexOf(tok); + while (i !== -1) { + const before = i === 0 ? '' : hay.charAt(i - 1); + if (!isWordChar(before)) { + const j = i + tok.length; + for (const suf of INFLECTIONS) { + const k = j + suf.length; + if (hay.slice(j, k) === suf && !isWordChar(k >= hay.length ? '' : hay.charAt(k))) return true; + } + } + i = hay.indexOf(tok, i + 1); + } + return false; +} const limit = parseInt(process.env.GSTACK_SEARCH_LIMIT || '10', 10); const entries = []; @@ -77,6 +121,14 @@ for (const taggedLine of lines) { const e = JSON.parse(line); if (!e.key || !e.type) continue; + // #2762: strip every underscore-prefixed key the moment a row is parsed. + // Internal ranking fields live in this namespace, and gstack-learnings-log + // re-serializes whatever it is handed, so a stored _field would otherwise + // arrive as a live sort key. Doing it here, once, immunizes every internal + // field including ones added later, instead of relying on each new one + // remembering to initialize itself. + for (const k of Object.keys(e)) { if (k.charAt(0) === '_') delete e[k]; } + // Apply confidence decay: observed/inferred lose 1pt per 30 days let conf = e.confidence || 5; if (e.source === 'observed' || e.source === 'inferred') { @@ -120,13 +172,53 @@ let results = Array.from(seen.values()); if (type) results = results.filter(e => e.type === type); // Filter by query (token-OR: match if ANY whitespace-split token appears in ANY haystack) +// #2762: recall and relevance are now two different questions, answered separately. +// +// RECALL (this predicate) is unchanged from before: substring containment across +// key, insight and files. Every entry that used to come back still comes back. +// +// RELEVANCE (the hit counts below) is a stricter, narrower measure, because reusing the +// recall net as a ranking signal ranks badly in two ways that were measured on +// gstack's own shipped queries: +// - Substring hits are not concept hits. Under /investigate's shipped query, +// 'bug' hits inside 'debug', 'cause' inside 'because', 'fix' inside +// 'fixture', so prose that merely says 'debug output interleaves because the +// fixture is parallel' outscores a real insight about root causes. Whole-word +// matching is what makes a hit mean the concept is present. +// - Naming is weaker evidence than substance, but it is not worthless. Keys and +// file paths are long and descriptive by convention, so scoring them equally +// with the insight lets verbosity beat quality: a thin entry whose key or file +// list happens to carry the query words would outrank a strong insight that +// answers it. Scoring them at zero overcorrects in the other direction, and an +// entry named exactly after the query then loses to incidental prose. +// +// So relevance is two tiers. What the entry SAYS (insight) decides first; what it +// is ABOUT (key and file paths) only orders entries that already tie on substance. +// Both are recall-neutral: the predicate below is unchanged. 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 key = (e.key || '').toLowerCase(); + const insight = (e.insight || '').toLowerCase(); + const files = (e.files || []).map(f => f.toLowerCase()); + e._insightHits = scoreTokens.filter(tok => hasWholeWord(insight, tok)).length; + e._contextHits = scoreTokens.filter(tok => hasWholeWord(key, tok) || files.some(f => hasWholeWord(f, tok))).length; + return queryTokens.some(tok => key.includes(tok) || insight.includes(tok) || files.some(f => f.includes(tok))); }); -// Sort by effective confidence desc, then recency +// How many entries survived the filters, before the limit truncates. Feeds the +// truncation notice on the summary line below. +const totalMatched = results.length; + +// Sort by insight relevance, then naming relevance, then effective confidence, +// then recency (#2762). No-query calls are unaffected: both hit counts are 0 for +// every entry, the first two comparisons always tie, and the order is exactly the +// previous confidence-then-recency one. Queried calls DO re-rank, single-token +// included -- that is the point of the change, and it is why relevance is measured +// on whole words rather than on the substrings the recall filter accepts. results.sort((a, b) => { + const aIns = a._insightHits || 0, bIns = b._insightHits || 0; + if (bIns !== aIns) return bIns - aIns; + const aCtx = a._contextHits || 0, bCtx = b._contextHits || 0; + if (bCtx !== aCtx) return bCtx - aCtx; if (b._effectiveConfidence !== a._effectiveConfidence) return b._effectiveConfidence - a._effectiveConfidence; return new Date(b.ts).getTime() - new Date(a.ts).getTime(); }); @@ -137,7 +229,7 @@ results = results.slice(0, limit); if (results.length === 0) process.exit(0); // Format output -const byType = {}; +const byType = Object.create(null); for (const e of results) { const t = e.type || 'unknown'; if (!byType[t]) byType[t] = []; @@ -145,8 +237,20 @@ for (const e of results) { } // Summary line +// #2762: when a query matched more than the limit shows, say so. A caller that +// searched for something specific and got back a full page of confident but +// unrelated entries would otherwise read the list as a complete answer. Gated on a +// non-empty query because the no-query preamble call (gstack-skill-start, --limit 3) +// runs every session and must not grow a line. Emitted on stdout because this block +// ends with its own stderr redirected to /dev/null, so stdout is the only channel +// that reaches a caller. Phrased as a whole-and-part after gstack-retro-metrics +// ('showing 300 of %d'), so the reader never has to add two numbers together, and +// stated once rather than beside a second count of the same number. const counts = Object.entries(byType).map(([t, arr]) => arr.length + ' ' + t + (arr.length > 1 ? 's' : '')); -console.log('LEARNINGS: ' + results.length + ' loaded (' + counts.join(', ') + ')'); +const truncated = queryTokens.length > 0 && totalMatched > results.length; +const loaded = truncated ? results.length + ' of ' + totalMatched + ' matched' : results.length + ' loaded'; +const hint = truncated ? '; raise --limit for the rest' : ''; +console.log('LEARNINGS: ' + loaded + ' (' + counts.join(', ') + ')' + hint); 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 ffa0227aed..cd012449ae 100644 --- a/test/gstack-learnings-search.test.ts +++ b/test/gstack-learnings-search.test.ts @@ -2,7 +2,7 @@ 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'; +import { execFileSync, spawnSync } from 'child_process'; const ROOT = path.resolve(import.meta.dir, '..'); const BIN = path.join(ROOT, 'bin', 'gstack-learnings-search'); @@ -15,7 +15,7 @@ const projDir = path.join(tmpHome, 'projects', slug); const otherProjDir = path.join(tmpHome, 'projects', 'other-project'); function run(args: string[]): string { - return execFileSync(BIN, args, { + return execFileSync('bash', [BIN, ...args], { timeout: 30_000, env: { ...process.env, GSTACK_HOME: tmpHome }, cwd: tmpCwd, @@ -45,6 +45,11 @@ beforeAll(() => { afterAll(() => { fs.rmSync(tmpHome, { recursive: true, force: true }); fs.rmSync(tmpCwd, { recursive: true, force: true }); + // #2762: rankCwd is created at module scope, so it must be removed at module + // scope too. A describe-scoped afterAll leaks it whenever a filtered run + // (bun test -t ...) skips that describe. + fs.rmSync(rankCwd, { recursive: true, force: true }); + fs.rmSync(badCwd, { recursive: true, force: true }); }); describe('gstack-learnings-search token-OR query semantics', () => { @@ -91,3 +96,397 @@ describe('gstack-learnings-search cross-project trust gating', () => { expect(out).not.toContain('foreign-legacy'); }); }); + +// #2762: relevance ranking. The query filter is token-OR over substrings, so a +// broad token can admit most of a store. Ranking on confidence alone then lets a +// high-confidence single-token match outrank an entry that matched every token, +// and the default --limit 10 truncates the exact answer off the end. The caller +// gets ten confident, well-formed, wrong entries and reads them as a complete +// answer -- a false absence, which is the dangerous failure direction. +// +// This fixture lives in its own project dir so the assertions above (which depend +// on a three-entry store) keep their meaning. Every entry is `user-stated` because +// that source is exempt from confidence decay -- an `observed` fixture would drift +// as wall-clock time passes and turn these into date-dependent flakes. Every entry +// is the same `type` so the formatter emits one group and printed order is rank order. +const rankCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-search-rank-cwd-')); +const rankSlug = path.basename(rankCwd).replace(/[^a-zA-Z0-9._-]/g, ''); +const rankProjDir = path.join(tmpHome, 'projects', rankSlug); + +// #2762: the malformed-row store lives apart from every other fixture because the +// defect it probes blanks the ENTIRE run, which would mask each of them in turn. +const badCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-search-bad-cwd-')); +const badSlug = path.basename(badCwd).replace(/[^a-zA-Z0-9._-]/g, ''); +const badProjDir = path.join(tmpHome, 'projects', badSlug); + +const TARGET = 'verify-preflight-project-line-before-trusting-report'; +// Twelve decoys, each matching ONLY the token `line`, via substring hits inside +// guideline / pipeline / deadline / etc. All outrank the target on confidence. +const DECOY_WORDS = [ + 'guideline', 'pipeline', 'deadline', 'headline', 'baseline', 'timeline', + 'outline', 'airline', 'lifeline', 'sideline', 'streamline', 'underline', +]; + +function rankEntry(over: Record): Record { + return { ts: '2026-05-01T00:00:00Z', skill: 'test', type: 'pattern', confidence: 8, source: 'user-stated', trusted: false, files: [], ...over }; +} + +// #2762 / I11: assert on the printed order as an ARRAY, never with indexOf +// comparisons. indexOf returns -1 for an absent key, and -1 is less than every +// real index, so `indexOf(a) < indexOf(b)` reports green when `a` has vanished +// entirely -- the exact false-absence this suite exists to catch. +function rankedKeys(args: string[]): string[] { + return runRank(args) + .split('\n') + .map(line => /^- \[([^\]]+)\]/.exec(line)) + .filter((m): m is RegExpExecArray => m !== null) + .map(m => m[1]); +} + +function runRank(args: string[]): string { + return execFileSync('bash', [BIN, ...args], { + timeout: 30_000, + env: { ...process.env, GSTACK_HOME: tmpHome }, + cwd: rankCwd, + encoding: 'utf-8', + }); +} + +describe('gstack-learnings-search relevance ranking (#2762)', () => { + beforeAll(() => { + fs.mkdirSync(rankProjDir, { recursive: true }); + const rows = [ + // Matches all three tokens of "preflight project line", at LOWER confidence + // than every decoy. This is the entry the caller is looking for. + rankEntry({ key: TARGET, insight: 'Check the project line in the preflight report before trusting it', confidence: 8 }), + // 1-of-3 matches (`line` only) at max confidence: enough of them to fill the + // default limit on their own. + ...DECOY_WORDS.map((w, i) => rankEntry({ + ts: '2026-05-' + String(4 + i).padStart(2, '0') + 'T00:00:00Z', + key: 'decoy-' + w + '-rule', + insight: 'A ' + w + ' related insight', + confidence: 10, + })), + // Tie-break probes for the query "alpha beta": 2 hits at confidence 9, 2 hits + // at confidence 5, 1 hit at confidence 10. Correct order is 9, 5, 10 -- hits + // outrank confidence, and confidence still breaks a tie between equal hits. + // The lower-confidence row is deliberately the NEWER one, so recency alone + // would order these backwards. Only the confidence tier produces the + // expected order, which is what makes the assertion able to fail. + rankEntry({ ts: '2026-05-01T00:00:00Z', key: 'tiebreak-alpha-beta-high', insight: 'alpha beta both present', confidence: 9 }), + rankEntry({ ts: '2026-06-01T00:00:00Z', key: 'tiebreak-alpha-beta-low', insight: 'alpha beta both present', confidence: 5 }), + rankEntry({ key: 'tiebreak-alpha-solo', insight: 'alpha only here', confidence: 10 }), + // Recency probes for "gamma delta": identical hits AND identical confidence, + // so the third comparison (recency) has to decide. + rankEntry({ ts: '2026-05-01T00:00:00Z', key: 'recency-gamma-delta-older', insight: 'gamma delta pair', confidence: 7 }), + rankEntry({ ts: '2026-06-01T00:00:00Z', key: 'recency-gamma-delta-newer', insight: 'gamma delta pair', confidence: 7 }), + // A row carrying a stored _tokenHits, the shape gstack-learnings-log will + // happily persist because it re-serializes unknown keys. Worst entry in the + // store on every legacy signal: lowest confidence, oldest timestamp. + rankEntry({ ts: '2020-01-01T00:00:00Z', key: 'planted-token-hits', insight: 'isolated poison row', confidence: 1, + _insightHits: 9999, _contextHits: 9999, _tokenHits: 9999, _somethingAddedLater: 9999 }), + // Substring-vs-word probes. The decoy satisfies "cause", "bug" and "fix" only + // as substrings (be-CAUSE, de-BUG, FIX-ture); the real answer contains three + // of them as whole words. + rankEntry({ key: 'nested-substring-decoy', insight: 'debug output ran because the fixture was parallel', confidence: 10 }), + rankEntry({ key: 'nested-whole-word-match', insight: 'form a root cause hypothesis first', confidence: 2 }), + // Key-verbosity probes. The verbose key carries four query tokens; its + // content carries none. The plain key carries none; its content carries one. + rankEntry({ key: 'kappa-lambda-sigma-omega-verbose-key', insight: 'unrelated content', confidence: 4 }), + rankEntry({ key: 'plain-key', insight: 'kappa appears here', confidence: 10 }), + // Naming-tier probes: identical insight relevance (both score 1 on "sigma"), + // so the key/file tier has to break the tie -- and it must beat confidence. + rankEntry({ key: 'tau-rho-xi-named', insight: 'tau noted', confidence: 2 }), + rankEntry({ key: 'unnamed-probe', insight: 'tau noted', confidence: 8 }), + // files is a scored field; give it tokens that appear nowhere else, so a hit + // can only have come from the path. + rankEntry({ key: 'path-carrier', insight: 'nothing relevant here', confidence: 3, files: ['test/zulu/yankee.test.ts'] }), + rankEntry({ key: 'insight-carrier', insight: 'zulu and yankee explained properly', confidence: 3, files: [] }), + // Non-ASCII boundary probes. 'chi' is a real word in the CJK row and only an + // incidental substring in 'chile'. + rankEntry({ key: 'accented-neighbour', insight: 'psi\u00e9 deploy', confidence: 5 }), + rankEntry({ key: 'ascii-neighbour', insight: 'psi deploy', confidence: 5 }), + // Dedup probes. The repeated-token entry must NOT also match the other + // concepts, or repetition inflates both rows equally and the ordering cannot + // reveal whether tokens were deduped. + rankEntry({ key: 'repeated-token-only', insight: 'nu only here', confidence: 9 }), + rankEntry({ key: 'two-distinct-concepts', insight: 'omicron and kirin together', confidence: 2 }), + // #2762: inflection probes. A query is written in the base form; prose is + // written in whatever form the sentence needs. The insight below carries one + // regular inflection of each query token and no token verbatim, so under + // exact-form scoring it takes ZERO insight hits and loses to a row that only + // has the words in its NAME -- the shipped `--query "" --limit 5` + // shape, where a real answer saying "tests interleave" lost to decoys whose + // only claim was a `.test.ts` filename. + rankEntry({ key: 'inflected-insight', insight: 'quarks and vortexes pulsed while orbited and drifting', confidence: 2 }), + rankEntry({ key: 'quark-vortex-pulse-orbit-drift-in-the-name', insight: 'nothing to say here', confidence: 10 }), + // Over-reach probe: 'orbital' merely STARTS with the query token. It is + // recalled by the substring filter, so it is present to be ranked, and it + // must score nothing -- suffix tolerance is a closed list of inflections, + // not a prefix match. + rankEntry({ key: 'orbital-overreach-decoy', insight: 'an orbital note about nothing', confidence: 10 }), + // Underscore separator probes. gstack-learnings-log's key regex admits + // [a-zA-Z0-9_-], so snake_case keys are supported and file paths carry + // underscores constantly. If `_` counts as a word character the whole key is + // one word and scores nothing, while a kebab-case key holding the same words + // scores fully -- the same entry ranked on its separator, not its content. + rankEntry({ key: 'iota_upsilon_probe', insight: 'no query words in this text', confidence: 2 }), + rankEntry({ key: 'plain-row-iota', insight: 'no query words in this text', confidence: 10 }), + // Decay-vs-relevance probes. Fixed at a date far enough back that the + // observed rows are pinned to the 0 floor by Math.max, so they are stable + // forever rather than drifting with wall clock -- which is why the rest of + // this fixture is user-stated. + rankEntry({ key: 'decayed-two-hits', insight: 'quasar and nebula both discussed', confidence: 9, source: 'observed', ts: '2019-01-01T00:00:00Z' }), + rankEntry({ key: 'current-one-hit', insight: 'quasar alone here', confidence: 10 }), + rankEntry({ key: 'decayed-tied-hit', insight: 'pulsar mentioned', confidence: 9, source: 'observed', ts: '2019-01-01T00:00:00Z' }), + rankEntry({ key: 'current-tied-hit', insight: 'pulsar mentioned too', confidence: 5 }), + ]; + fs.writeFileSync(path.join(rankProjDir, 'learnings.jsonl'), rows.map(e => JSON.stringify(e)).join('\n') + '\n'); + }); + + // The reported defect: adding a discriminating token made the search WORSE. + // On the pre-fix binary the first two queries find the target and the third + // does not, even though its key contains all three tokens. + test('an entry matching every query token survives the default limit', () => { + expect(runRank(['--query', 'preflight'])).toContain(TARGET); + expect(runRank(['--query', 'preflight project'])).toContain(TARGET); + expect(runRank(['--query', 'preflight project line'])).toContain(TARGET); + }); + + test('a 3-of-3 match outranks twelve higher-confidence 1-of-3 matches', () => { + expect(rankedKeys(['--query', 'preflight project line'])[0]).toBe(TARGET); + }); + + test('token hits outrank confidence, and confidence still breaks a hit tie', () => { + expect(rankedKeys(['--query', 'alpha beta'])).toEqual([ + 'tiebreak-alpha-beta-high', // 2 hits, confidence 9 + 'tiebreak-alpha-beta-low', // 2 hits, confidence 5 -- hits tie, confidence decides + 'tiebreak-alpha-solo', // 1 hit, confidence 10 -- outranked despite the best confidence + ]); + }); + + test('equal hits and equal confidence still fall through to recency', () => { + expect(rankedKeys(['--query', 'gamma delta'])).toEqual([ + 'recency-gamma-delta-newer', + 'recency-gamma-delta-older', + ]); + }); + + // Relevance applies to single-token queries too, and this is the case that shows + // why it has to. `line` appears in all twelve decoys only inside guideline, + // pipeline, deadline and friends; the target contains it as an actual word. The + // pre-fix binary ranked on confidence alone and truncated the target away. + test('a single token still discriminates a real word from an incidental substring', () => { + const ranked = rankedKeys(['--query', 'line']); + expect(ranked[0]).toBe(TARGET); + // Recall is untouched: the substring-only decoys are all still returned. + expect(ranked).toContain('decoy-underline-rule'); + }); + + test('a truncated query reports the part and the whole, stated once', () => { + const out = runRank(['--query', 'preflight project line']); + expect(out).toContain('LEARNINGS: 10 of 13 matched'); + expect(out).toContain('raise --limit for the rest'); + // The count is stated as a fraction instead of alongside a second copy of itself. + expect(out).not.toContain('10 loaded'); + }); + + test('a query that fits under the limit says nothing about truncation', () => { + const out = runRank(['--query', 'gamma delta']); + expect(out).toContain('recency-gamma-delta-newer'); + expect(out).toContain('loaded'); + expect(out).not.toContain('matched'); + }); + + // The preamble calls this with --limit 3 and no query on every skill invocation + // in every session. It must not grow a line. + test('the no-query preamble path never emits a truncation notice', () => { + const out = runRank(['--limit', '3']); + expect(out).toContain('LEARNINGS: 3 loaded'); + expect(out).not.toContain('matched'); + }); + + // The script ends with the bun stage's own stderr redirected to /dev/null, so + // stdout is the only channel that can reach a caller at all. Assert the notice + // is on it and that the process still succeeds. + test('the truncation notice is delivered on stdout with a zero exit', () => { + const res = spawnSync('bash', [BIN, '--query', 'preflight project line'], { + timeout: 30_000, + env: { ...process.env, GSTACK_HOME: tmpHome }, + cwd: rankCwd, + encoding: 'utf-8', + }); + expect(res.status).toBe(0); + expect(res.stdout).toContain('10 of 13 matched'); + }); + + // A stored internal field must never become a live sort key. The query filter is + // the only writer, so on the no-query path -- which gstack-skill-start runs at + // --limit 3 in every session -- an unstripped field would be read straight off + // disk. gstack-learnings-log persists unknown keys, so this row is a shape the + // supported writer can actually produce, not a hand-edit. The fixture plants the + // current sort fields AND a name that does not exist yet, because the defense is + // the underscore-namespace strip rather than a list of known fields. + test('a stored internal field cannot hijack the no-query preamble ranking', () => { + const ranked = rankedKeys(['--limit', '3']); + expect(ranked).not.toContain('planted-token-hits'); + expect(ranked[0]).not.toBe('planted-token-hits'); + }); + + // Relevance counts whole words, not substrings. Under /investigate's shipped + // query shape, substring scoring gave prose containing "because"/"debug"/ + // "fixture" three free hits and buried the real answer below it. + test('substring-only hits do not score, so incidental prose cannot outrank', () => { + const ranked = rankedKeys(['--query', 'root cause hypothesis bug fix']); + expect(ranked[0]).toBe('nested-whole-word-match'); + // Recall unchanged: the decoy still matches the substring filter and is returned. + expect(ranked).toContain('nested-substring-decoy'); + }); + + // Naming is weaker evidence than substance. A verbose key carrying four query + // tokens must not outrank an insight that actually says one of them. + test('a verbose key never outranks an insight that answers the query', () => { + const ranked = rankedKeys(['--query', 'kappa lambda sigma omega']); + expect(ranked[0]).toBe('plain-key'); + expect(ranked).toContain('kappa-lambda-sigma-omega-verbose-key'); + }); + + // But naming is not worthless: on an insight tie it breaks the tie, ahead of + // confidence. Without this tier an entry named exactly after the query loses to + // incidental prose and can be truncated away. + test('on an insight tie, key and file naming breaks it ahead of confidence', () => { + expect(rankedKeys(['--query', 'tau rho xi'])).toEqual([ + 'tau-rho-xi-named', // insight 1, naming 3, confidence 2 + 'unnamed-probe', // insight 1, naming 0, confidence 8 + ]); + }); + + test('a query token found only in files scores as naming, below a real insight', () => { + const ranked = rankedKeys(['--query', 'zulu yankee']); + expect(ranked[0]).toBe('insight-carrier'); + expect(ranked).toContain('path-carrier'); + }); + + test('a letter with an accent is a word character, so it does not fake a boundary', () => { + const ranked = rankedKeys(['--query', 'psi']); + expect(ranked[0]).toBe('ascii-neighbour'); + }); + + // Repeating a word must not promote an entry matching fewer concepts. Asserted + // as an OUTCOME, not by comparing two runs: a run-vs-run comparison cannot see + // uniform score inflation, so it stays green when the dedup is deleted. + test('a repeated query token cannot outrank an entry matching more concepts', () => { + // Deduped: repeated-token-only scores 1 (nu), two-distinct-concepts scores 2. + // Undeduped it would score 3 for the same one concept and take the lead, even + // though it answers less of the query and the other row is the better match. + expect(rankedKeys(['--query', 'nu nu nu omicron kirin'])).toEqual([ + 'two-distinct-concepts', // 2 distinct hits, confidence 2 + 'repeated-token-only', // 1 distinct hit, confidence 9 + ]); + }); + + // A query is written in the base form; prose is written in whatever form the + // sentence needs. Scoring only the exact form re-opens the truncation this fix + // exists to close: the answer is in the insight, the query words are only in + // someone else's name, and the name wins. + test('a base-form query scores against the inflected form in the insight', () => { + expect(rankedKeys(['--query', 'quark vortex pulse orbit drift'])).toEqual([ + 'inflected-insight', // 5 insight hits via -s -es -d -ed -ing, confidence 2 + 'quark-vortex-pulse-orbit-drift-in-the-name', // 0 insight, 5 naming, confidence 10 + 'orbital-overreach-decoy', // 0 insight, 0 naming, confidence 10 + ]); + }); + + test('inflection tolerance does not degrade into loose prefix matching', () => { + // 'orbited' is 'orbit' plus a listed inflection and scores; 'orbital' merely + // starts with it and must not, even though it holds the better confidence. + expect(rankedKeys(['--query', 'orbit'])).toEqual([ + 'inflected-insight', // 1 insight hit via -ed, confidence 2 + 'quark-vortex-pulse-orbit-drift-in-the-name', // 0 insight, 1 naming hit + 'orbital-overreach-decoy', // 0 hits, confidence 10 -- recalled, never scored + ]); + }); + + // Same entry, same words, different separator. Underscore must break words or a + // snake_case key scores nothing while its kebab-case twin scores fully. + test('an underscore separates words in a key, exactly as a hyphen does', () => { + expect(rankedKeys(['--query', 'iota upsilon'])).toEqual([ + 'iota_upsilon_probe', // 2 naming hits, confidence 2 + 'plain-row-iota', // 1 naming hit, confidence 10 + ]); + }); + + // #2762 ranks tokens matched ahead of confidence by design, and decay feeds + // confidence. This pins the consequence so it is deliberate and visible rather + // than discovered later: a fully decayed row that answers more of the query is + // still ranked above a current row that answers less of it. + test('relevance outranks confidence decay when the decayed row matches more', () => { + expect(rankedKeys(['--query', 'quasar nebula'])).toEqual([ + 'decayed-two-hits', // 2 hits, observed and decayed to the 0 floor + 'current-one-hit', // 1 hit, confidence 10, exempt from decay + ]); + }); + + // ...and decay is still live underneath it: once the hit counts tie, the decayed + // row loses on the confidence it has lost. + test('confidence decay still demotes a stale row once relevance ties', () => { + expect(rankedKeys(['--query', 'pulsar'])).toEqual([ + 'current-tied-hit', // 1 hit, confidence 5 + 'decayed-tied-hit', // 1 hit, decayed to 0 + ]); + }); +}); + +// The formatter groups rows into a plain object keyed by the row's own `type`. +// A row whose type names an inherited Object property finds that property already +// truthy, so the array is never created and .push throws. The bun stage ends with +// `2>/dev/null || exit 0`, which converts the throw into empty stdout at exit 0 -- +// indistinguishable from "this project has no learnings". Ranking is what makes it +// reachable: it promotes the malformed row past the --limit cut that used to hide it. +describe('gstack-learnings-search malformed row containment', () => { + const POISON_TYPES = ['constructor', 'toString', 'valueOf', 'hasOwnProperty', '__proto__']; + + beforeAll(() => { + fs.mkdirSync(badProjDir, { recursive: true }); + }); + + function writeBadStore(poisonType: string): void { + const rows = [ + ...Array.from({ length: 3 }, (_, i) => rankEntry({ + ts: '2026-05-0' + (i + 1) + 'T00:00:00Z', + key: 'healthy-' + i, + insight: 'alpha only insight ' + i, + confidence: 10, + })), + // Scores higher than every healthy row, so ranking floats it to the top. + rankEntry({ key: 'poison-row', type: poisonType, insight: 'alpha beta both here', confidence: 1 }), + ]; + fs.writeFileSync(path.join(badProjDir, 'learnings.jsonl'), rows.map(e => JSON.stringify(e)).join('\n') + '\n'); + } + + function runBad(args: string[]): ReturnType { + return spawnSync('bash', [BIN, ...args], { + timeout: 30_000, + env: { ...process.env, GSTACK_HOME: tmpHome }, + cwd: badCwd, + encoding: 'utf-8', + }); + } + + for (const poisonType of POISON_TYPES) { + test('a row typed "' + poisonType + '" cannot blank the whole store', () => { + writeBadStore(poisonType); + const res = runBad(['--query', 'alpha beta']); + const keys = String(res.stdout).split('\n') + .map(line => /^- \[([^\]]+)\]/.exec(line)) + .filter((m): m is RegExpExecArray => m !== null) + .map(m => m[1]); + // Every healthy row still reaches the caller. Asserted by presence, not by + // count: the failure mode is silence, and a count assertion on an empty + // result reads the same as a count assertion on a truncated one. + expect(keys).toContain('healthy-0'); + expect(keys).toContain('healthy-1'); + expect(keys).toContain('healthy-2'); + }); + } +});