diff --git a/__tests__/context-ranking.test.ts b/__tests__/context-ranking.test.ts index ec9772086..cc2889845 100644 --- a/__tests__/context-ranking.test.ts +++ b/__tests__/context-ranking.test.ts @@ -16,7 +16,7 @@ import * as path from 'path'; import * as os from 'os'; import CodeGraph from '../src/index'; import { LOW_CONFIDENCE_MARKER } from '../src/context'; -import { isDistinctiveIdentifier, scorePathRelevance, deriveProjectNameTokens } from '../src/search/query-utils'; +import { isDistinctiveIdentifier, scorePathRelevance, deriveProjectNameTokens, nameMatchBonus } from '../src/search/query-utils'; describe('isDistinctiveIdentifier', () => { it('treats plain dictionary words as non-distinctive', () => { @@ -39,6 +39,26 @@ describe('isDistinctiveIdentifier', () => { }); }); +// An empty / whitespace-only query has no name to match, so the bonus must be +// 0. Before the guard, `queryLower` collapsed to '' and `nameLower.startsWith('')` +// (always true) awarded a spurious flat +10 to every node. This is reachable via +// `searchNodes(' ')`, where the rescoring guard `text || query` passes a +// whitespace-only query through to nameMatchBonus. +describe('nameMatchBonus empty/whitespace query', () => { + it('returns 0 for an empty query', () => { + expect(nameMatchBonus('authenticate', '')).toBe(0); + }); + + it('returns 0 for a whitespace-only query', () => { + expect(nameMatchBonus('authenticate', ' ')).toBe(0); + expect(nameMatchBonus('anything', '\t\n')).toBe(0); + }); + + it('still scores a real query (exact match unaffected by the guard)', () => { + expect(nameMatchBonus('authenticate', 'authenticate')).toBe(80); + }); +}); + // A single PascalCase query word (notably a project name a user naturally // includes) splits into sub-tokens that all match the SAME path segment; summed // per sub-token it boosted that path 4×, burying the rest of the query's stack diff --git a/src/search/query-utils.ts b/src/search/query-utils.ts index 1a7b121fc..91702979e 100644 --- a/src/search/query-utils.ts +++ b/src/search/query-utils.ts @@ -343,6 +343,16 @@ function matchesNonProductionDir(lowerPath: string): boolean { export function nameMatchBonus(nodeName: string, query: string): number { const nameLower = nodeName.toLowerCase(); + // Full query as a single token (for compound identifiers like "CacheBuilder") + const queryLower = query.replace(/[\s]+/g, '').toLowerCase(); + + // An empty / whitespace-only query carries no name to match against. Bail + // before the `startsWith` check below, since `anyString.startsWith('')` is + // always true and would otherwise award a spurious flat +10 to every node + // (reachable via `searchNodes(' ')`, where the rescoring guard `text || + // query` lets a whitespace-only query through). + if (!queryLower) return 0; + // Split query into word-level terms (handles "CacheBuilder build" → ["cache","builder","build"]) const rawTerms = query .replace(/([a-z])([A-Z])/g, '$1 $2') @@ -353,9 +363,6 @@ export function nameMatchBonus(nodeName: string, query: string): number { // Also keep original space-separated tokens for exact-term matching const queryTokens = query.split(/\s+/).map(t => t.toLowerCase()).filter(t => t.length >= 2); - // Full query as a single token (for compound identifiers like "CacheBuilder") - const queryLower = query.replace(/[\s]+/g, '').toLowerCase(); - // Exact match: query exactly equals the node name if (nameLower === queryLower) return 80;