fix(MULTIVERSION-0001): fix version isolation, 404 on unknown version, commit-hash lookup, and searchModeUsed

Bug 1: Thread version tag from run() into crawl() via getVersionTag() helper so
LocalCrawler and GithubCrawler receive the correct ref when indexing a named
version instead of always crawling HEAD.

Bug 2: Return HTTP 404 with code VERSION_NOT_FOUND when a requested version tag
is not found in repository_versions, instead of silently falling back to a
cross-version mixed result set.

Bug 4: Before returning 404, attempt a commit_hash prefix match (min 7 chars)
so callers can request a version by full or short SHA.

Bug 3: Change HybridSearchService.search() to return
{ results, searchModeUsed } and propagate searchModeUsed through
ContextResponseMetadata and ContextJsonResponseDto so callers can see which
strategy (keyword / semantic / hybrid / keyword_fallback) was actually used.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Giancarmine Salucci
2026-03-28 10:31:15 +01:00
parent 417c6fd072
commit 255838dcc0
9 changed files with 217 additions and 41 deletions

View File

@@ -486,4 +486,83 @@ describe('API contract integration', () => {
isLocal: false
});
});
it('GET /api/v1/context returns 404 with VERSION_NOT_FOUND when version does not exist', async () => {
const repositoryId = seedRepo(db);
const response = await getContext({
url: new URL(
`http://test/api/v1/context?libraryId=${encodeURIComponent(`${repositoryId}/v99.0.0`)}&query=${encodeURIComponent('foo')}`
)
} as never);
expect(response.status).toBe(404);
const body = await response.json();
expect(body.code).toBe('VERSION_NOT_FOUND');
});
it('GET /api/v1/context resolves a version by full commit SHA', async () => {
const repositoryId = seedRepo(db);
const fullSha = 'a'.repeat(40);
// Insert version with a commit_hash
db.prepare(
`INSERT INTO repository_versions
(id, repository_id, tag, commit_hash, state, total_snippets, indexed_at, created_at)
VALUES (?, ?, ?, ?, 'indexed', 0, ?, ?)`
).run(`${repositoryId}/v2.0.0`, repositoryId, 'v2.0.0', fullSha, NOW_S, NOW_S);
const response = await getContext({
url: new URL(
`http://test/api/v1/context?libraryId=${encodeURIComponent(`${repositoryId}/${fullSha}`)}&query=${encodeURIComponent('anything')}`
)
} as never);
expect(response.status).toBe(200);
const body = await response.json();
expect(body.version?.resolved).toBe('v2.0.0');
});
it('GET /api/v1/context resolves a version by short SHA prefix (8 chars)', async () => {
const repositoryId = seedRepo(db);
const fullSha = 'b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0';
const shortSha = fullSha.slice(0, 8);
db.prepare(
`INSERT INTO repository_versions
(id, repository_id, tag, commit_hash, state, total_snippets, indexed_at, created_at)
VALUES (?, ?, ?, ?, 'indexed', 0, ?, ?)`
).run(`${repositoryId}/v3.0.0`, repositoryId, 'v3.0.0', fullSha, NOW_S, NOW_S);
const response = await getContext({
url: new URL(
`http://test/api/v1/context?libraryId=${encodeURIComponent(`${repositoryId}/${shortSha}`)}&query=${encodeURIComponent('anything')}`
)
} as never);
expect(response.status).toBe(200);
const body = await response.json();
expect(body.version?.resolved).toBe('v3.0.0');
});
it('GET /api/v1/context includes searchModeUsed in JSON response', async () => {
const repositoryId = seedRepo(db);
const documentId = seedDocument(db, repositoryId);
seedSnippet(db, {
documentId,
repositoryId,
content: 'search mode used test snippet'
});
const response = await getContext({
url: new URL(
`http://test/api/v1/context?libraryId=${encodeURIComponent(repositoryId)}&query=${encodeURIComponent('search mode used')}`
)
} as never);
expect(response.status).toBe(200);
const body = await response.json();
expect(body.searchModeUsed).toBeDefined();
expect(['keyword', 'semantic', 'hybrid', 'keyword_fallback']).toContain(body.searchModeUsed);
});
});

View File

@@ -198,6 +198,7 @@ export const GET: RequestHandler = async ({ url }) => {
let versionId: string | undefined;
let resolvedVersion: RawVersionRow | undefined;
if (parsed.version) {
// Try exact tag match first.
resolvedVersion = db
.prepare<
[string, string],
@@ -205,12 +206,33 @@ export const GET: RequestHandler = async ({ url }) => {
>(`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 = resolvedVersion?.id;
// Fall back to commit hash prefix match (min 7 chars).
if (!resolvedVersion && parsed.version.length >= 7) {
resolvedVersion = db
.prepare<
[string, string],
RawVersionRow
>(
`SELECT id, tag FROM repository_versions
WHERE repository_id = ? AND commit_hash LIKE ?`
)
.get(parsed.repositoryId, `${parsed.version}%`);
}
if (!resolvedVersion) {
return new Response(
JSON.stringify({
error: `Version ${parsed.version} not found for library ${parsed.repositoryId}`,
code: 'VERSION_NOT_FOUND'
}),
{ status: 404, headers: { 'Content-Type': 'application/json', ...CORS_HEADERS } }
);
}
versionId = resolvedVersion.id;
}
// Execute hybrid search (falls back to FTS5 when no embedding provider is set).
const searchResults = await hybridService.search(query, {
const { results: searchResults, searchModeUsed } = await hybridService.search(query, {
repositoryId: parsed.repositoryId,
versionId,
limit: 50, // fetch more than needed; token budget will trim
@@ -242,6 +264,7 @@ export const GET: RequestHandler = async ({ url }) => {
const metadata: ContextResponseMetadata = {
localSource: repo.source === 'local',
resultCount: selectedResults.length,
searchModeUsed,
repository: {
id: repo.id,
title: repo.title,