diff --git a/package.json b/package.json index 91e8c43..7cbe912 100644 --- a/package.json +++ b/package.json @@ -141,7 +141,7 @@ "chokidar": "^3.6.0", "fuse.js": "^7.0.0", "glob": "^10.3.10", - "hono": "^4.12.5", + "hono": "^4.12.12", "ignore": "^5.3.1", "typescript": "^5.3.3", "uuid": "^9.0.1", @@ -175,7 +175,7 @@ ], "overrides": { "@modelcontextprotocol/sdk>ajv": "8.18.0", - "@modelcontextprotocol/sdk>@hono/node-server": "1.19.11", + "@modelcontextprotocol/sdk>@hono/node-server": "1.19.13", "@modelcontextprotocol/sdk>express-rate-limit": "8.2.2", "@huggingface/transformers>onnxruntime-node": "1.24.2", "path-to-regexp": "8.4.0", @@ -185,7 +185,7 @@ "readdirp>picomatch": "2.3.2", "minimatch": "10.2.3", "rollup": "4.59.0", - "hono@<4.12.7": ">=4.12.7", + "hono@<4.12.12": ">=4.12.12", "tmp": "0.2.4" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0144fde..1ba5f09 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,7 +6,7 @@ settings: overrides: '@modelcontextprotocol/sdk>ajv': 8.18.0 - '@modelcontextprotocol/sdk>@hono/node-server': 1.19.11 + '@modelcontextprotocol/sdk>@hono/node-server': 1.19.13 '@modelcontextprotocol/sdk>express-rate-limit': 8.2.2 '@huggingface/transformers>onnxruntime-node': 1.24.2 path-to-regexp: 8.4.0 @@ -16,7 +16,7 @@ overrides: readdirp>picomatch: 2.3.2 minimatch: 10.2.3 rollup: 4.59.0 - hono@<4.12.7: '>=4.12.7' + hono@<4.12.12: '>=4.12.12' tmp: 0.2.4 importers: @@ -48,8 +48,8 @@ importers: specifier: ^10.3.10 version: 10.5.0 hono: - specifier: '>=4.12.7' - version: 4.12.7 + specifier: ^4.12.12 + version: 4.12.12 ignore: specifier: ^5.3.1 version: 5.3.2 @@ -318,11 +318,11 @@ packages: resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@hono/node-server@1.19.11': - resolution: {integrity: sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==} + '@hono/node-server@1.19.13': + resolution: {integrity: sha512-TsQLe4i2gvoTtrHje625ngThGBySOgSK3Xo2XRYOdqGN1teR8+I7vchQC46uLJi8OF62YTYA3AhSpumtkhsaKQ==} engines: {node: '>=18.14.1'} peerDependencies: - hono: '>=4.12.7' + hono: '>=4.12.12' '@huggingface/jinja@0.5.5': resolution: {integrity: sha512-xRlzazC+QZwr6z4ixEqYHo9fgwhTZ3xNSdljlKfUFGZSdlvt166DljRELFUfFytlYOYvo3vTisA/AFOuOAzFQQ==} @@ -1596,8 +1596,8 @@ packages: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} - hono@4.12.7: - resolution: {integrity: sha512-jq9l1DM0zVIvsm3lv9Nw9nlJnMNPOcAtsbsgiUhWcFzPE99Gvo6yRTlszSLLYacMeQ6quHD6hMfId8crVHvexw==} + hono@4.12.12: + resolution: {integrity: sha512-p1JfQMKaceuCbpJKAPKVqyqviZdS0eUxH9v82oWo1kb9xjQ5wA6iP3FNVAPDFlz5/p7d45lO+BpSk1tuSZMF4Q==} engines: {node: '>=16.9.0'} http-errors@2.0.1: @@ -2728,9 +2728,9 @@ snapshots: '@eslint/core': 0.17.0 levn: 0.4.1 - '@hono/node-server@1.19.11(hono@4.12.7)': + '@hono/node-server@1.19.13(hono@4.12.12)': dependencies: - hono: 4.12.7 + hono: 4.12.12 '@huggingface/jinja@0.5.5': {} @@ -2979,7 +2979,7 @@ snapshots: '@modelcontextprotocol/sdk@1.27.1(zod@4.3.4)': dependencies: - '@hono/node-server': 1.19.11(hono@4.12.7) + '@hono/node-server': 1.19.13(hono@4.12.12) ajv: 8.18.0 ajv-formats: 3.0.1(ajv@8.18.0) content-type: 1.0.5 @@ -2989,7 +2989,7 @@ snapshots: eventsource-parser: 3.0.6 express: 5.2.1 express-rate-limit: 8.2.2(express@5.2.1) - hono: 4.12.7 + hono: 4.12.12 jose: 6.1.3 json-schema-typed: 8.0.2 pkce-challenge: 5.0.1 @@ -4159,7 +4159,7 @@ snapshots: dependencies: function-bind: 1.1.2 - hono@4.12.7: {} + hono@4.12.12: {} http-errors@2.0.1: dependencies: diff --git a/src/preflight/evidence-lock.ts b/src/preflight/evidence-lock.ts index f5eddcf..5d9be15 100644 --- a/src/preflight/evidence-lock.ts +++ b/src/preflight/evidence-lock.ts @@ -44,6 +44,8 @@ interface BuildEvidenceLockInput { searchQualityStatus?: 'ok' | 'low_confidence'; /** Impact coverage: number of known callers covered by results */ impactCoverage?: { covered: number; total: number }; + /** Index age signal: fresh (<24h), aging (24h–7d), stale (>7d) */ + indexFreshness?: 'fresh' | 'aging' | 'stale'; } function strengthFactor(strength: EvidenceStrength): number { @@ -204,8 +206,22 @@ export function buildEvidenceLock(input: BuildEvidenceLockInput): EvidenceLock { } } + // Freshness gate: stale index forces block; aging index warns without changing status. + if (input.indexFreshness === 'stale') { + status = 'block'; + nextAction = 'Index is stale (>7 days). Run refresh_index before editing.'; + if (!gaps.includes('Index is stale')) { + gaps.push('Index is stale (>7 days) — results may be significantly out of date'); + } + } else if (input.indexFreshness === 'aging') { + if (!gaps.includes('Index is aging')) { + gaps.push('Index is aging (>24h) — results may not reflect recent changes'); + } + } + const readyToEdit = status === 'pass' && + input.indexFreshness !== 'stale' && (!epistemicStress || !epistemicStress.abstain) && input.searchQualityStatus !== 'low_confidence'; diff --git a/src/tools/search-codebase.ts b/src/tools/search-codebase.ts index e388171..189684c 100644 --- a/src/tools/search-codebase.ts +++ b/src/tools/search-codebase.ts @@ -21,13 +21,45 @@ import { import { assessSearchQuality } from '../core/search-quality.js'; import { IndexCorruptedError } from '../errors/index.js'; import { readMemoriesFile, withConfidence } from '../memory/store.js'; +import type { MemoryWithConfidence } from '../memory/store.js'; import { InternalFileGraph } from '../utils/usage-tracker.js'; +import type { FileExport } from '../utils/usage-tracker.js'; import { RELATIONSHIPS_FILENAME } from '../constants/codebase-context.js'; +// Stop words for compact-mode memory relevance filter (mirrors QUERY_STOP_WORDS in search.ts) +const COMPACT_STOP_WORDS = new Set([ + 'the', + 'a', + 'an', + 'to', + 'of', + 'for', + 'and', + 'or', + 'with', + 'in', + 'on', + 'by', + 'how', + 'are', + 'is', + 'after', + 'before', + 'from', + 'that', + 'this', + 'which', + 'what', + 'where', + 'when' +]); + interface RelationshipsData { graph?: { imports?: Record; importDetails?: Record>; + importedBy?: Record; + exports?: Record; }; stats?: unknown; } @@ -35,7 +67,10 @@ interface RelationshipsData { export const definition: Tool = { name: 'search_codebase', description: - 'Search the indexed codebase. Returns ranked results and a searchQuality confidence summary. ' + + 'Search the indexed codebase. Default compact mode returns at most 6 ranked results with ' + + 'light graph context (importedByCount, topExports, layer), a patternSummary, bestExample, ' + + 'nextHops, and response-budget metadata. Use mode="full" for today\'s richer response with ' + + 'full hints arrays and all memories — identical shape as before this parameter existed. ' + 'IMPORTANT: Pass the intent="edit"|"refactor"|"migrate" to get preflight: edit readiness check with evidence gating.', inputSchema: { type: 'object', @@ -44,6 +79,14 @@ export const definition: Tool = { type: 'string', description: 'Natural language search query' }, + mode: { + type: 'string', + enum: ['compact', 'full'], + description: + 'Response mode. compact (default): max 6 results with light graph context, pattern summary, ' + + "best example, next hops, and budget metadata. full: today's richer shape + budget metadata.", + default: 'compact' + }, intent: { type: 'string', enum: ['explore', 'edit', 'refactor', 'migrate'], @@ -98,12 +141,13 @@ export async function handle( args: Record, ctx: ToolContext ): Promise { - const { query, limit, filters, intent, includeSnippets } = args as { + const { query, limit, filters, intent, includeSnippets, mode } = args as { query?: unknown; limit?: number; filters?: Record; intent?: string; includeSnippets?: boolean; + mode?: string; }; const queryStr = typeof query === 'string' ? query.trim() : ''; @@ -377,14 +421,21 @@ export async function handle( return Array.from(candidates.values()).slice(0, 20); } - // Build reverse import map from relationships sidecar (preferred) or intelligence graph + // Build reverse import map: prefer pre-computed relationships.graph.importedBy, + // fall back to rebuilding from graph.imports for older index formats. const reverseImports = new Map(); - const importsGraph = getImportsGraph(); - if (importsGraph) { - for (const [file, deps] of Object.entries(importsGraph)) { - for (const dep of deps) { - if (!reverseImports.has(dep)) reverseImports.set(dep, []); - reverseImports.get(dep)!.push(file); + if (relationships?.graph?.importedBy) { + for (const [dep, importers] of Object.entries(relationships.graph.importedBy)) { + reverseImports.set(dep, importers); + } + } else { + const importsGraph = getImportsGraph(); + if (importsGraph) { + for (const [file, deps] of Object.entries(importsGraph)) { + for (const dep of deps) { + if (!reverseImports.has(dep)) reverseImports.set(dep, []); + reverseImports.get(dep)!.push(file); + } } } } @@ -397,7 +448,6 @@ export async function handle( }; hints?: { callers?: string[]; - consumers?: string[]; tests?: string[]; }; } @@ -421,8 +471,9 @@ export async function handle( // testedIn: heuristic — same basename with .spec/.test extension const testedIn: string[] = []; const baseName = path.basename(rPathNorm).replace(/\.[^.]+$/, ''); - if (importsGraph) { - for (const file of Object.keys(importsGraph)) { + const localImportsGraph = getImportsGraph(); + if (localImportsGraph) { + for (const file of Object.keys(localImportsGraph)) { const fileBase = path.basename(file); if ( (fileBase.includes('.spec.') || fileBase.includes('.test.')) && @@ -452,7 +503,7 @@ export async function handle( .slice(0, 3) .map(([file]) => file); hintsObj.callers = sortedCallers; - hintsObj.consumers = sortedCallers; // Same data, different label + // NOTE: consumers removed — it was identical to callers (pure token waste) } // Cap tests at 3 @@ -471,7 +522,6 @@ export async function handle( if (Object.keys(hintsObj).length > 0) { output.hints = hintsObj as { callers?: string[]; - consumers?: string[]; tests?: string[]; }; } @@ -479,6 +529,90 @@ export async function handle( return output; } + // Get the count of files that import a given result (compact mode graph context) + function getImportedByCount(result: SearchResult): number { + const rPathNorm = normalizeGraphPath(result.filePath); + const importers = new Set(); + for (const [dep, imps] of reverseImports) { + if (dep === rPathNorm || dep.endsWith(rPathNorm) || rPathNorm.endsWith(dep)) { + for (const imp of imps) importers.add(imp); + } + } + return importers.size; + } + + // Get up to maxCount named exports for a file from relationships.graph.exports + function getTopExports(filePath: string, maxCount = 3): string[] { + if (!relationships?.graph?.exports) return []; + const normalized = normalizeGraphPath(filePath); + const fileExports = relationships.graph.exports[normalized]; + if (!Array.isArray(fileExports)) return []; + return fileExports + .filter((e) => e.name !== 'default') + .slice(0, maxCount) + .map((e) => e.name); + } + + // Filter memories to only strongly relevant ones for compact mode: + // ≥2 non-stop-word query term matches AND effectiveConfidence ≥ 0.5 + function filterStrongMemories( + memories: MemoryWithConfidence[], + query: string + ): MemoryWithConfidence[] { + const terms = query + .toLowerCase() + .split(/\s+/) + .filter((t) => t.length > 2 && !COMPACT_STOP_WORDS.has(t)); + if (terms.length === 0) return []; + return memories + .filter((m) => { + const text = `${m.memory} ${m.reason}`.toLowerCase(); + const matchCount = terms.filter((t) => text.includes(t)).length; + return matchCount >= 2 && m.effectiveConfidence >= 0.5; + }) + .slice(0, 2); + } + + // Build a 1-line pattern summary string from intelligence.json patterns (compact mode) + function buildPatternSummary(): string | undefined { + const patterns = intelligence?.patterns; + if (!patterns) return undefined; + const entries = Object.values(patterns) + .filter((data) => Boolean(data.primary)) + .slice(0, 3) + .map((data) => { + const p = data.primary!; + return `${p.name} (${p.frequency}, ${p.trend ?? 'Stable'})`; + }); + return entries.length > 0 ? entries.join(', ') : undefined; + } + + // Return the top golden file path from intelligence.json (compact mode bestExample) + function getBestExample(): string | undefined { + return intelligence?.goldenFiles?.[0]?.file; + } + + // Build up to 2 dynamic next-hop suggestions (compact mode) + function buildNextHops( + compactResults: SearchResult[], + sq: { status: string } + ): Array<{ tool: string; args?: Record; why: string }> { + const hops: Array<{ tool: string; args?: Record; why: string }> = []; + if (compactResults.length > 0) { + hops.push({ + tool: 'read_file', + args: { path: compactResults[0].filePath }, + why: 'Read the top match for implementation details' + }); + } + if (sq.status === 'low_confidence') { + hops.push({ tool: 'get_team_patterns', why: 'Review pattern guidance — confidence is low' }); + } else if (hops.length < 2) { + hops.push({ tool: 'get_team_patterns', why: 'Review team patterns and best examples' }); + } + return hops.slice(0, 2); + } + const searchQuality = assessSearchQuality(queryStr, results); // Always-on edit preflight (lite): do not require intent and keep payload small. @@ -523,6 +657,8 @@ export async function handle( failureWarnings: [], patternConflicts: [], searchQualityStatus: searchQuality.status + // Note: indexFreshness intentionally omitted — explore intent is for navigation, + // not mutation; freshness gating does not apply. }) }; } catch { @@ -618,7 +754,17 @@ export async function handle( const graphDataSource = relationships?.graph || intelligence?.internalFileGraph; if (graphDataSource) { try { - const graph = InternalFileGraph.fromJSON(graphDataSource, ctx.rootPath); + const graph = InternalFileGraph.fromJSON( + graphDataSource as { + imports?: Record; + exports?: Record; + importDetails?: Record< + string, + Record + >; + }, + ctx.rootPath + ); // Use directory prefixes as scope (not full file paths) // findCycles(scope) filters files by startsWith, so a full path would only match itself const scopes = new Set( @@ -700,7 +846,8 @@ export async function handle( failureWarnings, patternConflicts, searchQualityStatus: searchQuality.status, - impactCoverage + impactCoverage, + indexFreshness: computeIndexConfidence() }); // Build clean decision card (PREF-01 to PREF-04) @@ -718,6 +865,14 @@ export async function handle( decisionCard.warnings = failureWarnings.slice(0, 3).map((w) => w.memory); } + // Surface freshness gap warnings (aging/stale) into the decision card + const freshnessGaps = (evidenceLock.gaps ?? []).filter( + (g) => g.includes('aging') || g.includes('stale') + ); + if (freshnessGaps.length > 0) { + decisionCard.warnings = [...(decisionCard.warnings ?? []), ...freshnessGaps]; + } + // Add patterns (do/avoid, capped at 3 each, with adoption %) const doPatterns = preferredPatternsForOutput .slice(0, 3) @@ -762,6 +917,12 @@ export async function handle( decisionCard.whatWouldHelp = evidenceLock.whatWouldHelp; } + // Soft-abstain: block status OR low-confidence search quality signals insufficient + // evidence for edit guidance. Results are still returned (soft abstain per APPROACH Decision 2). + if (evidenceLock.status === 'block' || searchQuality.status === 'low_confidence') { + decisionCard.abstain = true; + } + preflight = decisionCard; } catch { // Preflight construction failed — skip preflight, don't fail the search @@ -834,6 +995,69 @@ export async function handle( return `// ${scopeHeader}\n${cleanedSnippet}`; } + const searchQualityBlock = { + status: searchQuality.status, + confidence: searchQuality.confidence, + ...(searchQuality.status === 'low_confidence' && + searchQuality.nextSteps?.[0] && { + hint: searchQuality.nextSteps[0] + }) + }; + + // Compact mode (default): bounded response with light graph context + const isCompact = mode !== 'full'; + + if (isCompact) { + const compactResults = results.slice(0, 6); + const strongMemories = filterStrongMemories(relatedMemories, queryStr); + const patternSummary = buildPatternSummary(); + const bestExample = getBestExample(); + const nextHops = buildNextHops(compactResults, searchQuality); + + return { + content: [ + { + type: 'text', + text: JSON.stringify( + { + status: 'success', + searchQuality: searchQualityBlock, + budget: { mode: 'compact', resultCount: compactResults.length }, + ...(preflightPayload && { preflight: preflightPayload }), + ...(patternSummary && { patternSummary }), + ...(bestExample && { bestExample }), + ...(nextHops.length > 0 && { nextHops }), + results: compactResults.map((r) => { + const importedByCount = getImportedByCount(r); + const topExports = getTopExports(r.filePath); + return { + file: `${r.filePath}:${r.startLine}-${r.endLine}`, + summary: r.summary, + score: Math.round(r.score * 100) / 100, + ...(r.relevanceReason && { relevanceReason: r.relevanceReason }), + ...(r.componentType && + r.layer && + r.layer !== 'unknown' && { type: `${r.componentType}:${r.layer}` }), + ...(r.trend && r.trend !== 'Stable' && { trend: r.trend }), + ...(r.patternWarning && { patternWarning: r.patternWarning }), + importedByCount, + ...(topExports.length > 0 && { topExports }), + ...(r.layer && r.layer !== 'unknown' && { layer: r.layer }) + }; + }), + ...(strongMemories.length > 0 && { + relatedMemories: strongMemories.map((m) => `${m.memory} (${m.effectiveConfidence})`) + }) + }, + null, + 2 + ) + } + ] + }; + } + + // Full mode: today's response shape + budget + relevanceReason; consumers removed return { content: [ { @@ -841,14 +1065,8 @@ export async function handle( text: JSON.stringify( { status: 'success', - searchQuality: { - status: searchQuality.status, - confidence: searchQuality.confidence, - ...(searchQuality.status === 'low_confidence' && - searchQuality.nextSteps?.[0] && { - hint: searchQuality.nextSteps[0] - }) - }, + searchQuality: searchQualityBlock, + budget: { mode: 'full', resultCount: results.length }, ...(preflightPayload && { preflight: preflightPayload }), results: results.map((r) => { const relationshipsAndHints = buildRelationshipHints(r); @@ -860,6 +1078,7 @@ export async function handle( file: `${r.filePath}:${r.startLine}-${r.endLine}`, summary: r.summary, score: Math.round(r.score * 100) / 100, + ...(r.relevanceReason && { relevanceReason: r.relevanceReason }), ...(r.componentType && r.layer && r.layer !== 'unknown' && { type: `${r.componentType}:${r.layer}` }), diff --git a/src/tools/types.ts b/src/tools/types.ts index 9a3e6d7..00fa6c0 100644 --- a/src/tools/types.ts +++ b/src/tools/types.ts @@ -3,6 +3,8 @@ import type { IndexingStats } from '../types/index.js'; export interface DecisionCard { ready: boolean; + /** True when the tool soft-abstains from edit guidance (e.g. stale index or low confidence + edit intent) */ + abstain?: boolean; nextAction?: string; warnings?: string[]; patterns?: { @@ -80,7 +82,6 @@ export interface SearchResultItem { }; hints?: { callers?: string[]; - consumers?: string[]; tests?: string[]; }; snippet?: string; diff --git a/tests/multi-project-routing.test.ts b/tests/multi-project-routing.test.ts index 3ee1d68..6240082 100644 --- a/tests/multi-project-routing.test.ts +++ b/tests/multi-project-routing.test.ts @@ -579,7 +579,7 @@ describe('multi-project routing', () => { })) as ResourceReadResponse; expect(response.contents[0]?.uri).toBe(CONTEXT_RESOURCE_URI); - expect(response.contents[0]?.text).toContain('# Codebase Intelligence'); + expect(response.contents[0]?.text).toContain('# Codebase Map'); expect(response.contents[0]?.text).not.toContain('Project selection required'); }); @@ -660,7 +660,7 @@ describe('multi-project routing', () => { })) as ResourceReadResponse; expect(response.contents[0]?.uri).toBe(buildProjectContextResourceUri(payload.project.project)); - expect(response.contents[0]?.text).toContain('# Codebase Intelligence'); + expect(response.contents[0]?.text).toContain('# Codebase Map'); }); it('returns unknown_project error when project path does not exist', async () => { diff --git a/tests/search-compact-mode.test.ts b/tests/search-compact-mode.test.ts new file mode 100644 index 0000000..f551d1a --- /dev/null +++ b/tests/search-compact-mode.test.ts @@ -0,0 +1,549 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { promises as fs } from 'fs'; +import os from 'os'; +import path from 'path'; +import { + CODEBASE_CONTEXT_DIRNAME, + INDEX_FORMAT_VERSION, + INDEX_META_FILENAME, + INDEX_META_VERSION, + INTELLIGENCE_FILENAME, + KEYWORD_INDEX_FILENAME, + RELATIONSHIPS_FILENAME, + VECTOR_DB_DIRNAME +} from '../src/constants/codebase-context.js'; + +const searchMocks = vi.hoisted(() => ({ + search: vi.fn() +})); + +vi.mock('../src/core/search.js', async () => { + class CodebaseSearcher { + constructor(_rootPath: string) {} + + async search(query: string, limit: number, filters?: unknown) { + return searchMocks.search(query, limit, filters); + } + } + + return { CodebaseSearcher }; +}); + +// A minimal SearchResult-like object for mocking +function makeResult(overrides: Record = {}) { + return { + summary: 'Auth service token management', + snippet: 'export class AuthService { getToken() { return token; } }', + filePath: 'src/auth/auth.service.ts', + startLine: 1, + endLine: 20, + score: 0.85, + language: 'ts', + metadata: {}, + relevanceReason: 'Matches auth token query', + ...overrides + }; +} + +describe('search_codebase compact/full mode', () => { + let tempRoot: string | null = null; + let originalArgv: string[] | null = null; + let originalEnvRoot: string | undefined; + + beforeEach(async () => { + searchMocks.search.mockReset(); + vi.resetModules(); + + originalArgv = [...process.argv]; + originalEnvRoot = process.env.CODEBASE_ROOT; + + tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'search-compact-mode-test-')); + process.env.CODEBASE_ROOT = tempRoot; + process.argv[2] = tempRoot; + + const ctxDir = path.join(tempRoot, CODEBASE_CONTEXT_DIRNAME); + await fs.mkdir(ctxDir, { recursive: true }); + + const buildId = 'test-build-compact'; + const generatedAt = new Date().toISOString(); + + await fs.mkdir(path.join(ctxDir, VECTOR_DB_DIRNAME), { recursive: true }); + await fs.writeFile( + path.join(ctxDir, VECTOR_DB_DIRNAME, 'index-build.json'), + JSON.stringify({ buildId, formatVersion: INDEX_FORMAT_VERSION }), + 'utf-8' + ); + + await fs.writeFile( + path.join(ctxDir, KEYWORD_INDEX_FILENAME), + JSON.stringify({ header: { buildId, formatVersion: INDEX_FORMAT_VERSION }, chunks: [] }), + 'utf-8' + ); + + await fs.writeFile( + path.join(ctxDir, INDEX_META_FILENAME), + JSON.stringify( + { + metaVersion: INDEX_META_VERSION, + formatVersion: INDEX_FORMAT_VERSION, + buildId, + generatedAt, + toolVersion: 'test', + artifacts: { + keywordIndex: { path: KEYWORD_INDEX_FILENAME }, + vectorDb: { path: VECTOR_DB_DIRNAME, provider: 'lancedb' }, + intelligence: { path: INTELLIGENCE_FILENAME } + } + }, + null, + 2 + ), + 'utf-8' + ); + + await fs.writeFile( + path.join(ctxDir, INTELLIGENCE_FILENAME), + JSON.stringify( + { + header: { buildId, formatVersion: INDEX_FORMAT_VERSION }, + generatedAt, + internalFileGraph: { + imports: { + 'src/app/app.module.ts': ['src/auth/auth.service.ts'], + 'src/app/login.component.ts': ['src/auth/auth.service.ts'] + } + }, + patterns: { + stateManagement: { + primary: { + name: 'Signals', + frequency: '78%', + trend: 'Rising' + } + }, + componentArchitecture: { + primary: { + name: 'Standalone Components', + frequency: '97%', + trend: 'Stable' + } + } + }, + goldenFiles: [ + { file: 'src/auth/auth.service.ts', score: 0.95 }, + { file: 'src/app/app.module.ts', score: 0.8 } + ] + }, + null, + 2 + ), + 'utf-8' + ); + + // Write relationships.json with header + importedBy and exports + await fs.writeFile( + path.join(ctxDir, RELATIONSHIPS_FILENAME), + JSON.stringify( + { + header: { buildId, formatVersion: INDEX_FORMAT_VERSION }, + stats: { files: 5, edges: 10 }, + graph: { + imports: { + 'src/app/app.module.ts': ['src/auth/auth.service.ts'], + 'src/app/login.component.ts': ['src/auth/auth.service.ts'] + }, + importedBy: { + 'src/auth/auth.service.ts': ['src/app/app.module.ts', 'src/app/login.component.ts'] + }, + exports: { + 'src/auth/auth.service.ts': [ + { name: 'AuthService', type: 'class' }, + { name: 'AuthToken', type: 'interface' }, + { name: 'getToken', type: 'function' } + ] + } + } + }, + null, + 2 + ), + 'utf-8' + ); + }); + + afterEach(async () => { + if (originalArgv) process.argv = originalArgv; + if (originalEnvRoot === undefined) { + delete process.env.CODEBASE_ROOT; + } else { + process.env.CODEBASE_ROOT = originalEnvRoot; + } + + if (tempRoot) { + await fs.rm(tempRoot, { recursive: true, force: true }); + tempRoot = null; + } + }); + + // Test 1: Default (compact) mode caps results at 6 + it('default compact mode returns at most 6 results even when search returns more', async () => { + // Return 10 results + const manyResults = Array.from({ length: 10 }, (_, i) => + makeResult({ filePath: `src/file${i}.ts`, startLine: i * 10 + 1, endLine: i * 10 + 20 }) + ); + searchMocks.search.mockResolvedValueOnce(manyResults); + + const { server } = await import('../src/index.js'); + const handler = ( + server as { + _requestHandlers?: Map< + string, + (r: unknown) => Promise<{ content: Array<{ type: string; text: string }> }> + >; + } + )._requestHandlers?.get('tools/call'); + if (!handler) throw new Error('Expected tools/call handler'); + + const response = await handler({ + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { name: 'search_codebase', arguments: { query: 'auth service', limit: 10 } } + }); + + const payload = JSON.parse(response.content[0].text) as { + status: string; + budget: { mode: string; resultCount: number }; + results: unknown[]; + }; + expect(payload.status).toBe('success'); + expect(payload.budget.mode).toBe('compact'); + expect(payload.results.length).toBeLessThanOrEqual(6); + expect(payload.budget.resultCount).toBeLessThanOrEqual(6); + }); + + // Test 2: Compact results include importedByCount and topExports when available + it('compact results include importedByCount and topExports from relationships.json', async () => { + searchMocks.search.mockResolvedValueOnce([makeResult()]); + + const { server } = await import('../src/index.js'); + const handler = ( + server as { + _requestHandlers?: Map< + string, + (r: unknown) => Promise<{ content: Array<{ type: string; text: string }> }> + >; + } + )._requestHandlers?.get('tools/call'); + if (!handler) throw new Error('Expected tools/call handler'); + + const response = await handler({ + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { name: 'search_codebase', arguments: { query: 'auth service' } } + }); + + const payload = JSON.parse(response.content[0].text) as { + results: Array<{ importedByCount: number; topExports?: string[] }>; + }; + const result = payload.results[0]; + expect(typeof result.importedByCount).toBe('number'); + // Expect 2 importers (app.module.ts + login.component.ts) + expect(result.importedByCount).toBe(2); + // topExports should be present (we wrote exports for this file) + expect(Array.isArray(result.topExports)).toBe(true); + expect(result.topExports).toContain('AuthService'); + }); + + // Test 3: Compact results do NOT include hints or consumers fields + it('compact results do not include hints or consumers fields', async () => { + searchMocks.search.mockResolvedValueOnce([makeResult()]); + + const { server } = await import('../src/index.js'); + const handler = ( + server as { + _requestHandlers?: Map< + string, + (r: unknown) => Promise<{ content: Array<{ type: string; text: string }> }> + >; + } + )._requestHandlers?.get('tools/call'); + if (!handler) throw new Error('Expected tools/call handler'); + + const response = await handler({ + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { name: 'search_codebase', arguments: { query: 'auth service' } } + }); + + const payload = JSON.parse(response.content[0].text) as { + results: Array>; + }; + const result = payload.results[0]; + expect(result.hints).toBeUndefined(); + expect((result as Record).consumers).toBeUndefined(); + }); + + // Test 4: Compact response includes budget, patternSummary, bestExample, nextHops + it('compact response includes budget, patternSummary, bestExample, and nextHops', async () => { + searchMocks.search.mockResolvedValueOnce([makeResult()]); + + const { server } = await import('../src/index.js'); + const handler = ( + server as { + _requestHandlers?: Map< + string, + (r: unknown) => Promise<{ content: Array<{ type: string; text: string }> }> + >; + } + )._requestHandlers?.get('tools/call'); + if (!handler) throw new Error('Expected tools/call handler'); + + const response = await handler({ + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { name: 'search_codebase', arguments: { query: 'auth service' } } + }); + + const payload = JSON.parse(response.content[0].text) as { + budget: { mode: string; resultCount: number }; + patternSummary?: string; + bestExample?: string; + nextHops?: Array<{ tool: string; why: string }>; + }; + expect(payload.budget).toBeDefined(); + expect(payload.budget.mode).toBe('compact'); + // patternSummary should include pattern info from intelligence.json + expect(typeof payload.patternSummary).toBe('string'); + expect(payload.patternSummary).toContain('Signals'); + // bestExample should be the top golden file + expect(payload.bestExample).toBe('src/auth/auth.service.ts'); + // nextHops should have at least 1 entry + expect(Array.isArray(payload.nextHops)).toBe(true); + expect(payload.nextHops?.length ?? 0).toBeGreaterThan(0); + }); + + // Test 5: Full mode returns hints arrays and all memories + budget + it('full mode returns hints object with callers/tests and budget metadata', async () => { + searchMocks.search.mockResolvedValueOnce([makeResult()]); + + const { server } = await import('../src/index.js'); + const handler = ( + server as { + _requestHandlers?: Map< + string, + (r: unknown) => Promise<{ content: Array<{ type: string; text: string }> }> + >; + } + )._requestHandlers?.get('tools/call'); + if (!handler) throw new Error('Expected tools/call handler'); + + const response = await handler({ + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { name: 'search_codebase', arguments: { query: 'auth service', mode: 'full' } } + }); + + const payload = JSON.parse(response.content[0].text) as { + budget: { mode: string; resultCount: number }; + results: Array>; + totalResults: number; + }; + expect(payload.budget.mode).toBe('full'); + expect(typeof payload.totalResults).toBe('number'); + // Full mode includes hints object (callers present because of relationships) + const result = payload.results[0]; + expect(result.hints).toBeDefined(); + const hints = result.hints as Record; + expect(Array.isArray(hints.callers)).toBe(true); + }); + + // Test 6: relevanceReason appears in results in both modes + it('relevanceReason is included in results for both compact and full modes', async () => { + searchMocks.search.mockResolvedValueOnce([ + makeResult({ relevanceReason: 'Token management for auth flows' }) + ]); + + const { server } = await import('../src/index.js'); + const handler = ( + server as { + _requestHandlers?: Map< + string, + (r: unknown) => Promise<{ content: Array<{ type: string; text: string }> }> + >; + } + )._requestHandlers?.get('tools/call'); + if (!handler) throw new Error('Expected tools/call handler'); + + // Compact mode + const compactResponse = await handler({ + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { name: 'search_codebase', arguments: { query: 'auth token' } } + }); + const compactPayload = JSON.parse(compactResponse.content[0].text) as { + results: Array<{ relevanceReason?: string }>; + }; + expect(compactPayload.results[0].relevanceReason).toBe('Token management for auth flows'); + + searchMocks.search.mockResolvedValueOnce([ + makeResult({ relevanceReason: 'Token management for auth flows' }) + ]); + + // Full mode + const fullResponse = await handler({ + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { name: 'search_codebase', arguments: { query: 'auth token', mode: 'full' } } + }); + const fullPayload = JSON.parse(fullResponse.content[0].text) as { + results: Array<{ relevanceReason?: string }>; + }; + expect(fullPayload.results[0].relevanceReason).toBe('Token management for auth flows'); + }); + + // Test 7: consumers field is absent in both modes + it('consumers field is absent from hints in both compact and full modes', async () => { + searchMocks.search.mockResolvedValueOnce([makeResult()]); + + const { server } = await import('../src/index.js'); + const handler = ( + server as { + _requestHandlers?: Map< + string, + (r: unknown) => Promise<{ content: Array<{ type: string; text: string }> }> + >; + } + )._requestHandlers?.get('tools/call'); + if (!handler) throw new Error('Expected tools/call handler'); + + // Compact + const compactResp = await handler({ + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { name: 'search_codebase', arguments: { query: 'auth service' } } + }); + const compactPayload = JSON.parse(compactResp.content[0].text) as { + results: Array>; + }; + const compactResult = compactPayload.results[0]; + expect(compactResult.consumers).toBeUndefined(); + if (compactResult.hints) { + expect((compactResult.hints as Record).consumers).toBeUndefined(); + } + + searchMocks.search.mockResolvedValueOnce([makeResult()]); + + // Full + const fullResp = await handler({ + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { name: 'search_codebase', arguments: { query: 'auth service', mode: 'full' } } + }); + const fullPayload = JSON.parse(fullResp.content[0].text) as { + results: Array>; + }; + const fullResult = fullPayload.results[0]; + expect(fullResult.consumers).toBeUndefined(); + if (fullResult.hints) { + expect((fullResult.hints as Record).consumers).toBeUndefined(); + } + }); + + // Test 8: Strongly relevant memory filter — weak matches excluded in compact, included in full + it('compact mode excludes weak memories; full mode includes all keyword-matched memories', async () => { + if (!tempRoot) throw new Error('tempRoot not initialized'); + + // Seed a memory file with a weak (single-term) match memory + const ctxDir = path.join(tempRoot, CODEBASE_CONTEXT_DIRNAME); + await fs.writeFile( + path.join(ctxDir, 'memories.json'), + JSON.stringify([ + { + id: 'mem001', + type: 'convention', + category: 'auth', + memory: 'Always inject AuthService via constructor, never manually instantiate', + reason: 'Ensures testability and DI compliance', + date: new Date().toISOString(), + source: 'user' + }, + { + id: 'mem002', + type: 'convention', + category: 'general', + // This memory only matches a single stop word from the query — should be excluded in compact + memory: 'Use consistent spacing', + reason: 'Style guide compliance', + date: new Date().toISOString(), + source: 'user' + } + ]), + 'utf-8' + ); + + // Query with multiple non-stop-word terms that match mem001 but not mem002 + searchMocks.search.mockResolvedValueOnce([makeResult()]); + + const { server } = await import('../src/index.js'); + const handler = ( + server as { + _requestHandlers?: Map< + string, + (r: unknown) => Promise<{ content: Array<{ type: string; text: string }> }> + >; + } + )._requestHandlers?.get('tools/call'); + if (!handler) throw new Error('Expected tools/call handler'); + + // Compact: only strongly relevant memories (≥2 non-stop-word matches + confidence ≥ 0.5) + const compactResp = await handler({ + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { + name: 'search_codebase', + arguments: { query: 'inject AuthService constructor testing' } + } + }); + const compactPayload = JSON.parse(compactResp.content[0].text) as { + relatedMemories?: string[]; + }; + // mem001 matches "inject", "AuthService", "constructor" — strong match + // mem002 only matches "spacing" (if at all) — weak/no match → excluded + if (compactPayload.relatedMemories) { + expect(compactPayload.relatedMemories.some((m) => m.includes('AuthService'))).toBe(true); + expect(compactPayload.relatedMemories.some((m) => m.includes('consistent spacing'))).toBe( + false + ); + } + + searchMocks.search.mockResolvedValueOnce([makeResult()]); + + // Full: all keyword-matched memories (single-term match is sufficient) + const fullResp = await handler({ + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { + name: 'search_codebase', + arguments: { query: 'inject AuthService constructor testing', mode: 'full' } + } + }); + const fullPayload = JSON.parse(fullResp.content[0].text) as { + relatedMemories?: string[]; + }; + // Full mode: keyword match (any term). "inject" matches mem001. + if (fullPayload.relatedMemories) { + expect(fullPayload.relatedMemories.some((m) => m.includes('AuthService'))).toBe(true); + } + }); +}); diff --git a/tests/search-decision-card.test.ts b/tests/search-decision-card.test.ts index eb4c8e1..5453b7f 100644 --- a/tests/search-decision-card.test.ts +++ b/tests/search-decision-card.test.ts @@ -300,6 +300,7 @@ export class ProfileService { name: 'search_codebase', arguments: { query: 'getToken', + mode: 'full', includeSnippets: true } } @@ -359,6 +360,7 @@ export class ProfileService { name: 'search_codebase', arguments: { query: 'getToken', + mode: 'full', includeSnippets: true } } diff --git a/tests/search-safe-01.test.ts b/tests/search-safe-01.test.ts new file mode 100644 index 0000000..bee7c75 --- /dev/null +++ b/tests/search-safe-01.test.ts @@ -0,0 +1,454 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { promises as fs } from 'fs'; +import os from 'os'; +import path from 'path'; +import { + CODEBASE_CONTEXT_DIRNAME, + INDEX_FORMAT_VERSION, + INDEX_META_FILENAME, + INDEX_META_VERSION, + INTELLIGENCE_FILENAME, + KEYWORD_INDEX_FILENAME, + RELATIONSHIPS_FILENAME, + VECTOR_DB_DIRNAME +} from '../src/constants/codebase-context.js'; + +const searchMocks = vi.hoisted(() => ({ + search: vi.fn() +})); + +vi.mock('../src/core/search.js', async () => { + class CodebaseSearcher { + constructor(_rootPath: string) {} + + async search(query: string, limit: number, filters?: unknown) { + return searchMocks.search(query, limit, filters); + } + } + + return { CodebaseSearcher }; +}); + +// A minimal SearchResult-like object for mocking +function makeResult(overrides: Record = {}) { + return { + summary: 'Auth service token management', + snippet: 'export class AuthService { getToken() { return token; } }', + filePath: 'src/auth/auth.service.ts', + startLine: 1, + endLine: 20, + score: 0.82, + language: 'ts', + metadata: {}, + relevanceReason: 'Matches auth token query', + ...overrides + }; +} + +// Produces a strong set of 3+ results with good scores (> 0.4) +function makeStrongResults() { + return [ + makeResult({ filePath: 'src/auth/auth.service.ts', score: 0.85 }), + makeResult({ filePath: 'src/auth/token.service.ts', score: 0.78 }), + makeResult({ filePath: 'src/auth/interceptor.ts', score: 0.71 }) + ]; +} + +// Returns ISO string for N hours ago +function hoursAgo(n: number): string { + return new Date(Date.now() - n * 60 * 60 * 1000).toISOString(); +} + +describe('search_codebase SAFE-01 edit-readiness gating', () => { + let tempRoot: string | null = null; + let originalArgv: string[] | null = null; + let originalEnvRoot: string | undefined; + + async function seedIndex(generatedAt: string) { + if (!tempRoot) throw new Error('tempRoot not set'); + const ctxDir = path.join(tempRoot, CODEBASE_CONTEXT_DIRNAME); + await fs.mkdir(ctxDir, { recursive: true }); + + const buildId = 'test-build-safe01'; + + await fs.mkdir(path.join(ctxDir, VECTOR_DB_DIRNAME), { recursive: true }); + await fs.writeFile( + path.join(ctxDir, VECTOR_DB_DIRNAME, 'index-build.json'), + JSON.stringify({ buildId, formatVersion: INDEX_FORMAT_VERSION }), + 'utf-8' + ); + + await fs.writeFile( + path.join(ctxDir, KEYWORD_INDEX_FILENAME), + JSON.stringify({ header: { buildId, formatVersion: INDEX_FORMAT_VERSION }, chunks: [] }), + 'utf-8' + ); + + await fs.writeFile( + path.join(ctxDir, INDEX_META_FILENAME), + JSON.stringify( + { + metaVersion: INDEX_META_VERSION, + formatVersion: INDEX_FORMAT_VERSION, + buildId, + generatedAt, + toolVersion: 'test', + artifacts: { + keywordIndex: { path: KEYWORD_INDEX_FILENAME }, + vectorDb: { path: VECTOR_DB_DIRNAME, provider: 'lancedb' }, + intelligence: { path: INTELLIGENCE_FILENAME } + } + }, + null, + 2 + ), + 'utf-8' + ); + + await fs.writeFile( + path.join(ctxDir, INTELLIGENCE_FILENAME), + JSON.stringify( + { + header: { buildId, formatVersion: INDEX_FORMAT_VERSION }, + generatedAt, + internalFileGraph: { + imports: { + 'src/app/app.module.ts': ['src/auth/auth.service.ts'], + 'src/app/login.component.ts': ['src/auth/auth.service.ts'] + } + }, + patterns: { + componentArchitecture: { + primary: { + name: 'Standalone Components', + frequency: '97%', + trend: 'Stable' + } + }, + stateManagement: { + primary: { + name: 'Signals', + frequency: '78%', + trend: 'Rising' + } + } + }, + goldenFiles: [{ file: 'src/auth/auth.service.ts', score: 0.95 }] + }, + null, + 2 + ), + 'utf-8' + ); + + await fs.writeFile( + path.join(ctxDir, RELATIONSHIPS_FILENAME), + JSON.stringify( + { + header: { buildId, formatVersion: INDEX_FORMAT_VERSION }, + stats: { files: 5, edges: 10 }, + graph: { + imports: { + 'src/app/app.module.ts': ['src/auth/auth.service.ts'], + 'src/app/login.component.ts': ['src/auth/auth.service.ts'] + }, + importedBy: { + 'src/auth/auth.service.ts': ['src/app/app.module.ts', 'src/app/login.component.ts'] + }, + exports: { + 'src/auth/auth.service.ts': [ + { name: 'AuthService', type: 'class' }, + { name: 'getToken', type: 'function' } + ] + } + } + }, + null, + 2 + ), + 'utf-8' + ); + } + + beforeEach(async () => { + searchMocks.search.mockReset(); + vi.resetModules(); + + originalArgv = [...process.argv]; + originalEnvRoot = process.env.CODEBASE_ROOT; + + tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'search-safe01-test-')); + process.env.CODEBASE_ROOT = tempRoot; + process.argv[2] = tempRoot; + }); + + afterEach(async () => { + if (originalArgv) process.argv = originalArgv; + if (originalEnvRoot === undefined) { + delete process.env.CODEBASE_ROOT; + } else { + process.env.CODEBASE_ROOT = originalEnvRoot; + } + + if (tempRoot) { + await fs.rm(tempRoot, { recursive: true, force: true }); + tempRoot = null; + } + }); + + // Test 1: Low-confidence + edit intent → preflight.abstain === true, preflight.ready === false + it('low-confidence retrieval with edit intent sets abstain=true and ready=false', async () => { + // 0 results → assessSearchQuality returns low_confidence immediately + searchMocks.search.mockResolvedValueOnce([]); + await seedIndex(new Date().toISOString()); // fresh index + + const { server } = await import('../src/index.js'); + const handler = ( + server as { + _requestHandlers?: Map< + string, + (r: unknown) => Promise<{ content: Array<{ type: string; text: string }> }> + >; + } + )._requestHandlers?.get('tools/call'); + if (!handler) throw new Error('Expected tools/call handler'); + + const response = await handler({ + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { + name: 'search_codebase', + arguments: { query: 'auth token service', intent: 'edit' } + } + }); + + const payload = JSON.parse(response.content[0].text) as { + status: string; + preflight?: { ready: boolean; abstain?: boolean; nextAction?: string }; + results: unknown[]; + }; + + expect(payload.status).toBe('success'); + expect(payload.preflight).toBeDefined(); + expect(payload.preflight!.ready).toBe(false); + expect(payload.preflight!.abstain).toBe(true); + // Results still returned (soft abstain) + expect(Array.isArray(payload.results)).toBe(true); + }); + + // Test 2: Fresh index + edit intent → normal preflight (no abstain) + it('fresh index with strong evidence and edit intent does not set abstain', async () => { + searchMocks.search.mockResolvedValueOnce(makeStrongResults()); + await seedIndex(new Date().toISOString()); // fresh + + const { server } = await import('../src/index.js'); + const handler = ( + server as { + _requestHandlers?: Map< + string, + (r: unknown) => Promise<{ content: Array<{ type: string; text: string }> }> + >; + } + )._requestHandlers?.get('tools/call'); + if (!handler) throw new Error('Expected tools/call handler'); + + const response = await handler({ + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { + name: 'search_codebase', + arguments: { query: 'auth token service', intent: 'edit' } + } + }); + + const payload = JSON.parse(response.content[0].text) as { + status: string; + preflight?: { ready: boolean; abstain?: boolean }; + results: unknown[]; + }; + + expect(payload.status).toBe('success'); + expect(payload.preflight).toBeDefined(); + // No abstain for fresh index with good evidence + expect(payload.preflight!.abstain).toBeUndefined(); + expect(payload.results.length).toBeGreaterThan(0); + }); + + // Test 3: Aging index + edit intent → preflight.warnings includes aging notice, ready can be true + it('aging index with edit intent surfaces aging warning without blocking', async () => { + searchMocks.search.mockResolvedValueOnce(makeStrongResults()); + await seedIndex(hoursAgo(25)); // aging: >24h, <168h + + const { server } = await import('../src/index.js'); + const handler = ( + server as { + _requestHandlers?: Map< + string, + (r: unknown) => Promise<{ content: Array<{ type: string; text: string }> }> + >; + } + )._requestHandlers?.get('tools/call'); + if (!handler) throw new Error('Expected tools/call handler'); + + const response = await handler({ + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { + name: 'search_codebase', + arguments: { query: 'auth token service', intent: 'edit' } + } + }); + + const payload = JSON.parse(response.content[0].text) as { + status: string; + preflight?: { ready: boolean; abstain?: boolean; warnings?: string[] }; + results: unknown[]; + }; + + expect(payload.status).toBe('success'); + expect(payload.preflight).toBeDefined(); + // No hard block for aging + expect(payload.preflight!.abstain).toBeUndefined(); + // Aging warning surfaced + const warnings = payload.preflight!.warnings ?? []; + const hasAgingWarning = warnings.some((w) => w.toLowerCase().includes('aging')); + expect(hasAgingWarning).toBe(true); + // Results still returned + expect(payload.results.length).toBeGreaterThan(0); + }); + + // Test 4: Stale index + edit intent → abstain=true, ready=false, nextAction mentions refresh_index + it('stale index with edit intent sets abstain=true and includes refresh_index guidance', async () => { + searchMocks.search.mockResolvedValueOnce(makeStrongResults()); + await seedIndex(hoursAgo(8 * 24)); // stale: >168h (8 days) + + const { server } = await import('../src/index.js'); + const handler = ( + server as { + _requestHandlers?: Map< + string, + (r: unknown) => Promise<{ content: Array<{ type: string; text: string }> }> + >; + } + )._requestHandlers?.get('tools/call'); + if (!handler) throw new Error('Expected tools/call handler'); + + const response = await handler({ + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { + name: 'search_codebase', + arguments: { query: 'auth token service', intent: 'edit' } + } + }); + + const payload = JSON.parse(response.content[0].text) as { + status: string; + preflight?: { ready: boolean; abstain?: boolean; nextAction?: string }; + results: unknown[]; + }; + + expect(payload.status).toBe('success'); + expect(payload.preflight).toBeDefined(); + expect(payload.preflight!.ready).toBe(false); + expect(payload.preflight!.abstain).toBe(true); + // nextAction must mention refresh_index + expect(payload.preflight!.nextAction).toMatch(/refresh_index/i); + // Results still returned (soft abstain) + expect(Array.isArray(payload.results)).toBe(true); + }); + + // Test 5: Explore intent with stale index → no abstain field, results returned normally + it('explore intent with stale index has no abstain field and returns results', async () => { + searchMocks.search.mockResolvedValueOnce(makeStrongResults()); + await seedIndex(hoursAgo(8 * 24)); // stale: >168h + + const { server } = await import('../src/index.js'); + const handler = ( + server as { + _requestHandlers?: Map< + string, + (r: unknown) => Promise<{ content: Array<{ type: string; text: string }> }> + >; + } + )._requestHandlers?.get('tools/call'); + if (!handler) throw new Error('Expected tools/call handler'); + + const response = await handler({ + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { + name: 'search_codebase', + arguments: { query: 'auth token service', intent: 'explore' } + } + }); + + const payload = JSON.parse(response.content[0].text) as { + status: string; + preflight?: { ready: boolean; abstain?: boolean }; + results: unknown[]; + }; + + expect(payload.status).toBe('success'); + // Explore intent uses lite preflight path — no abstain field + if (payload.preflight) { + expect(payload.preflight.abstain).toBeUndefined(); + } + // Results are returned regardless + expect(payload.results.length).toBeGreaterThan(0); + }); + + // Test 6: indexFreshness correctly propagated — aging gaps appear in preflight + it('indexFreshness aging signal propagates to preflight evidence gaps', async () => { + searchMocks.search.mockResolvedValueOnce(makeStrongResults()); + await seedIndex(hoursAgo(48)); // 48h — aging + + const { server } = await import('../src/index.js'); + const handler = ( + server as { + _requestHandlers?: Map< + string, + (r: unknown) => Promise<{ content: Array<{ type: string; text: string }> }> + >; + } + )._requestHandlers?.get('tools/call'); + if (!handler) throw new Error('Expected tools/call handler'); + + const response = await handler({ + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { + name: 'search_codebase', + arguments: { query: 'auth token service', intent: 'refactor' } + } + }); + + const payload = JSON.parse(response.content[0].text) as { + status: string; + preflight?: { + ready: boolean; + abstain?: boolean; + warnings?: string[]; + nextAction?: string; + }; + }; + + expect(payload.status).toBe('success'); + expect(payload.preflight).toBeDefined(); + // indexFreshness=aging should not block — no abstain + expect(payload.preflight!.abstain).toBeUndefined(); + // Aging warning must be surfaced via warnings + const warnings = payload.preflight!.warnings ?? []; + const nextAction = payload.preflight!.nextAction ?? ''; + const hasAgingSignal = + warnings.some((w) => w.toLowerCase().includes('aging')) || + nextAction.toLowerCase().includes('aging'); + expect(hasAgingSignal).toBe(true); + }); +}); diff --git a/tests/search-snippets.test.ts b/tests/search-snippets.test.ts index 5f90ba0..457b8c8 100644 --- a/tests/search-snippets.test.ts +++ b/tests/search-snippets.test.ts @@ -116,6 +116,7 @@ export const VERSION = '1.0.0'; name: 'search_codebase', arguments: { query: 'getToken', + mode: 'full', includeSnippets: true } } @@ -145,6 +146,7 @@ export const VERSION = '1.0.0'; name: 'search_codebase', arguments: { query: 'getToken', + mode: 'full', includeSnippets: true } } @@ -203,6 +205,7 @@ export const VERSION = '1.0.0'; name: 'search_codebase', arguments: { query: 'formatDate', + mode: 'full', includeSnippets: true } }