fix(FEEDBACK-0001): complete iteration 0 - harden context search

This commit is contained in:
Giancarmine Salucci
2026-03-27 01:25:46 +01:00
parent e7a2a83cdb
commit 16436bfab2
15 changed files with 1469 additions and 44 deletions

View File

@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import Database from 'better-sqlite3';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import crypto from 'node:crypto';
import { RepositoryService } from '$lib/server/services/repository.service';
import { VersionService } from '$lib/server/services/version.service';
@@ -24,21 +25,34 @@ vi.mock('$lib/server/pipeline/startup.js', () => ({
getQueue: () => queue
}));
vi.mock('$lib/server/embeddings/registry', () => ({
createProviderFromProfile: () => null
}));
vi.mock('$lib/server/embeddings/registry.js', () => ({
createProviderFromProfile: () => null
}));
import { POST as postLibraries } from './libs/+server.js';
import { GET as getLibrary } from './libs/[id]/+server.js';
import { GET as getJobs } from './jobs/+server.js';
import { GET as getJob } from './jobs/[id]/+server.js';
import { GET as getVersions, POST as postVersions } from './libs/[id]/versions/+server.js';
import { GET as getContext } from './context/+server.js';
const NOW_S = Math.floor(Date.now() / 1000);
function createTestDb(): Database.Database {
const client = new Database(':memory:');
client.pragma('foreign_keys = ON');
const migrationsFolder = join(import.meta.dirname, '../../../lib/server/db/migrations');
const ftsFile = join(import.meta.dirname, '../../../lib/server/db/fts.sql');
// Apply all migration files in order
const migration0 = readFileSync(join(migrationsFolder, '0000_large_master_chief.sql'), 'utf-8');
const migration1 = readFileSync(join(migrationsFolder, '0001_quick_nighthawk.sql'), 'utf-8');
const migration2 = readFileSync(join(migrationsFolder, '0002_silky_stellaris.sql'), 'utf-8');
// Apply first migration
const statements0 = migration0
@@ -60,9 +74,126 @@ function createTestDb(): Database.Database {
client.exec(statement);
}
const statements2 = migration2
.split('--> statement-breakpoint')
.map((statement) => statement.trim())
.filter(Boolean);
for (const statement of statements2) {
client.exec(statement);
}
client.exec(readFileSync(ftsFile, 'utf-8'));
return client;
}
function seedRepo(
client: Database.Database,
overrides: {
id?: string;
title?: string;
source?: 'github' | 'local';
sourceUrl?: string;
state?: 'pending' | 'indexing' | 'indexed' | 'error';
} = {}
): string {
const id = overrides.id ?? '/facebook/react';
client
.prepare(
`INSERT INTO repositories
(id, title, source, source_url, state, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`
)
.run(
id,
overrides.title ?? 'React',
overrides.source ?? 'github',
overrides.sourceUrl ?? 'https://github.com/facebook/react',
overrides.state ?? 'indexed',
NOW_S,
NOW_S
);
return id;
}
function seedVersion(client: Database.Database, repositoryId: string, tag: string): string {
const versionId = `${repositoryId}/${tag}`;
client
.prepare(
`INSERT INTO repository_versions
(id, repository_id, tag, state, total_snippets, indexed_at, created_at)
VALUES (?, ?, ?, 'indexed', 0, ?, ?)`
)
.run(versionId, repositoryId, tag, NOW_S, NOW_S);
return versionId;
}
function seedDocument(
client: Database.Database,
repositoryId: string,
versionId: string | null = null
): string {
const documentId = crypto.randomUUID();
client
.prepare(
`INSERT INTO documents (id, repository_id, version_id, file_path, checksum, indexed_at)
VALUES (?, ?, ?, ?, ?, ?)`
)
.run(documentId, repositoryId, versionId, 'README.md', 'checksum', NOW_S);
return documentId;
}
function seedSnippet(
client: Database.Database,
options: {
documentId: string;
repositoryId: string;
versionId?: string | null;
type?: 'code' | 'info';
title?: string | null;
content: string;
language?: string | null;
breadcrumb?: string | null;
tokenCount?: number;
}
): string {
const snippetId = crypto.randomUUID();
client
.prepare(
`INSERT INTO snippets
(id, document_id, repository_id, version_id, type, title, content, language, breadcrumb, token_count, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
)
.run(
snippetId,
options.documentId,
options.repositoryId,
options.versionId ?? null,
options.type ?? 'info',
options.title ?? null,
options.content,
options.language ?? null,
options.breadcrumb ?? null,
options.tokenCount ?? 0,
NOW_S
);
return snippetId;
}
function seedRules(client: Database.Database, repositoryId: string, rules: string[]) {
client
.prepare(
`INSERT INTO repository_configs (repository_id, rules, updated_at)
VALUES (?, ?, ?)`
)
.run(repositoryId, JSON.stringify(rules), NOW_S);
}
describe('API contract integration', () => {
beforeEach(() => {
db = createTestDb();
@@ -174,4 +305,78 @@ describe('API contract integration', () => {
expect(getBody.versions[0]).not.toHaveProperty('repository_id');
expect(getBody.versions[0]).not.toHaveProperty('total_snippets');
});
it('GET /api/v1/context returns informative txt output for empty results', async () => {
const repositoryId = seedRepo(db);
const response = await getContext({
url: new URL(
`http://test/api/v1/context?libraryId=${encodeURIComponent(repositoryId)}&query=${encodeURIComponent('no matches here')}&type=txt`
)
} as never);
expect(response.status).toBe(200);
expect(response.headers.get('content-type')).toContain('text/plain');
const body = await response.text();
expect(body).toContain('## Context Results');
expect(body).toContain('No matching snippets found');
expect(body).toContain('Repository: React (/facebook/react)');
expect(body).toContain('Result count: 0');
});
it('GET /api/v1/context returns additive repository and version metadata for versioned results', async () => {
const repositoryId = seedRepo(db);
const versionId = seedVersion(db, repositoryId, 'v18.3.0');
const documentId = seedDocument(db, repositoryId, versionId);
seedRules(db, repositoryId, ['Prefer hooks over classes']);
seedSnippet(db, {
documentId,
repositoryId,
versionId,
type: 'code',
title: 'useThing',
content: 'export function useThing() { return true; }',
language: 'ts',
breadcrumb: 'Hooks > useThing',
tokenCount: 42
});
const response = await getContext({
url: new URL(
`http://test/api/v1/context?libraryId=${encodeURIComponent(`${repositoryId}/v18.3.0`)}&query=${encodeURIComponent('useThing')}`
)
} as never);
expect(response.status).toBe(200);
const body = await response.json();
expect(body.snippets).toHaveLength(1);
expect(body.rules).toEqual(['Prefer hooks over classes']);
expect(body.totalTokens).toBe(42);
expect(body.localSource).toBe(false);
expect(body.resultCount).toBe(1);
expect(body.repository).toEqual({
id: '/facebook/react',
title: 'React',
source: 'github',
sourceUrl: 'https://github.com/facebook/react',
branch: 'main',
isLocal: false
});
expect(body.version).toEqual({
requested: 'v18.3.0',
resolved: 'v18.3.0',
id: '/facebook/react/v18.3.0'
});
expect(body.snippets[0].origin).toEqual({
repositoryId: '/facebook/react',
repositoryTitle: 'React',
source: 'github',
sourceUrl: 'https://github.com/facebook/react',
version: 'v18.3.0',
versionId: '/facebook/react/v18.3.0',
isLocal: false
});
});
});

View File

@@ -25,6 +25,7 @@ import {
formatContextTxt,
CORS_HEADERS
} from '$lib/server/api/formatters';
import type { ContextResponseMetadata } from '$lib/server/mappers/context-response.mapper';
// ---------------------------------------------------------------------------
// Helpers
@@ -67,7 +68,32 @@ function getRules(db: ReturnType<typeof getClient>, repositoryId: string): strin
interface RawRepoState {
state: 'pending' | 'indexing' | 'indexed' | 'error';
id: string;
title: string;
source: 'github' | 'local';
source_url: string;
branch: string | null;
}
interface RawVersionRow {
id: string;
tag: string;
}
function getSnippetVersionTags(
db: ReturnType<typeof getClient>,
versionIds: string[]
): Record<string, string> {
if (versionIds.length === 0) return {};
const placeholders = versionIds.map(() => '?').join(', ');
const rows = db
.prepare<string[], RawVersionRow>(
`SELECT id, tag FROM repository_versions WHERE id IN (${placeholders})`
)
.all(...versionIds);
return Object.fromEntries(rows.map((row) => [row.id, row.tag]));
}
// ---------------------------------------------------------------------------
@@ -131,7 +157,9 @@ export const GET: RequestHandler = async ({ url }) => {
// Verify the repository exists and check its state.
const repo = db
.prepare<[string], RawRepoState>(`SELECT state, title FROM repositories WHERE id = ?`)
.prepare<[string], RawRepoState>(
`SELECT id, state, title, source, source_url, branch FROM repositories WHERE id = ?`
)
.get(parsed.repositoryId);
if (!repo) {
@@ -162,15 +190,16 @@ export const GET: RequestHandler = async ({ url }) => {
// Resolve version ID if a specific version was requested.
let versionId: string | undefined;
let resolvedVersion: RawVersionRow | undefined;
if (parsed.version) {
const versionRow = db
.prepare<[string, string], { id: string }>(
`SELECT id FROM repository_versions WHERE repository_id = ? AND tag = ?`
resolvedVersion = db
.prepare<[string, string], RawVersionRow>(
`SELECT id, tag FROM repository_versions WHERE repository_id = ? AND tag = ?`
)
.get(parsed.repositoryId, parsed.version);
// Version not found is not fatal — fall back to default branch.
versionId = versionRow?.id;
versionId = resolvedVersion?.id;
}
// Execute hybrid search (falls back to FTS5 when no embedding provider is set).
@@ -193,11 +222,39 @@ export const GET: RequestHandler = async ({ url }) => {
return found;
});
const snippetVersionIds = Array.from(
new Set(
selectedResults
.map((result) => result.snippet.versionId)
.filter((value): value is string => Boolean(value))
)
);
const snippetVersions = getSnippetVersionTags(db, snippetVersionIds);
const metadata: ContextResponseMetadata = {
localSource: repo.source === 'local',
resultCount: selectedResults.length,
repository: {
id: repo.id,
title: repo.title,
source: repo.source,
sourceUrl: repo.source_url,
branch: repo.branch
},
version: parsed.version || resolvedVersion
? {
requested: parsed.version ?? null,
resolved: resolvedVersion?.tag ?? null,
id: resolvedVersion?.id ?? null
}
: null,
snippetVersions
};
// Load rules from repository_configs.
const rules = getRules(db, parsed.repositoryId);
if (responseType === 'txt') {
const text = formatContextTxt(selectedResults, rules);
const text = formatContextTxt(selectedResults, rules, metadata);
return new Response(text, {
status: 200,
headers: {
@@ -208,7 +265,7 @@ export const GET: RequestHandler = async ({ url }) => {
}
// Default: JSON
const body = formatContextJson(selectedResults, rules);
const body = formatContextJson(selectedResults, rules, metadata);
return dtoJsonResponse(body, {
status: 200,
headers: CORS_HEADERS