mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-02 02:11:29 +00:00
* fix(docker): install OpenSSH in the CLI runtime image Auto-sync requires git SSH remotes, but the published image omitted openssh-client so every clone failed with ssh: not found. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(auto-sync): accept HTTPS remotes and reclone failed checkouts Allowlisted HTTPS URLs can clone without SSH keys, and a timed-out clone with no remote.origin is quarantined instead of blocking forever. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(auto-sync): honor .gitnexusrc embeddings and warn on empty vectors Auto-sync analyze now reads embeddings from the clone's project config, and query reports when an index has no vectors so keyword fallback is visible. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(review): warn when CodeEmbedding table is missing (U5) Co-authored-by: Cursor <cursoragent@cursor.com> * fix(ci): wrap long openssh-client test line for prettier Co-authored-by: Cursor <cursoragent@cursor.com> * fix(query): keep keyword-only indexes off query.warning Empty or missing CodeEmbedding is the default index. Put the #3372 notice in a once-per-backend log line so FTS-success query results stay warning-free. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(review): bound rc reads and quarantine only a missing origin Drop the implementation plan from the branch. Auto-sync reads .gitnexusrc through the bounded control-file reader, and a git config failure no longer relocates a live checkout. The same allowlisted repo can switch between SSH and HTTPS without a refused pull. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor(auto-sync): share repo identity and skip a second origin read Co-authored-by: Cursor <cursoragent@cursor.com> * test(auto-sync): clean temp fixtures and cover nested embeddings precedence Co-authored-by: Cursor <cursoragent@cursor.com> * test(auto-sync): skip the symlink rc fixture on Windows Co-authored-by: Cursor <cursoragent@cursor.com> * test(auto-sync): reject symlink rc files on every platform Co-authored-by: Cursor <cursoragent@cursor.com> * test(ci): run auto-sync symlink and clone tests on Windows and macOS Co-authored-by: Cursor <cursoragent@cursor.com> * test(git-clone): keep Windows CI on file URLs and POSIX permission checks Co-authored-by: Cursor <cursoragent@cursor.com> * test(git-clone): keep the SSH-to-HTTPS origin check offline Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com> Co-authored-by: Cursor <cursoragent@cursor.com>
144 lines
5.3 KiB
TypeScript
144 lines
5.3 KiB
TypeScript
/**
|
|
* Tests that MCP semantic search surfaces a missing local embedding stack once
|
|
* instead of silently degrading to BM25 (#2372) — the silent-degradation mode
|
|
* #2370 exists to fix. executeQuery is mocked to report a populated embedding
|
|
* table so execution reaches the embedder import, which is mocked to throw the
|
|
* missing-stack message (R20 copy: default install no longer ships the stack).
|
|
*/
|
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
import { _captureLogger, type LoggerCapture } from '../../src/core/logger.js';
|
|
import {
|
|
LOCAL_EMBEDDING_SIDECAR_ABORT_LEAD,
|
|
localEmbeddingStackMissingMessage,
|
|
} from '../../src/core/embeddings/runtime-support.js';
|
|
|
|
const executeQueryMock = vi.fn();
|
|
const embedQueryMock = vi.fn();
|
|
|
|
vi.mock('../../src/core/lbug/pool-adapter.js', async (importOriginal) => ({
|
|
...(await importOriginal<typeof import('../../src/core/lbug/pool-adapter.js')>()),
|
|
executeQuery: (...args: unknown[]) => executeQueryMock(...args),
|
|
}));
|
|
vi.mock('../../src/mcp/core/embedder.js', () => ({
|
|
embedQuery: (...args: unknown[]) => embedQueryMock(...args),
|
|
getEmbeddingDims: () => 384,
|
|
}));
|
|
|
|
import { LocalBackend } from '../../src/mcp/local/local-backend.js';
|
|
|
|
interface SemanticSearchable {
|
|
semanticSearch(
|
|
repo: { lbugPath: string },
|
|
query: string,
|
|
limit: number,
|
|
degraded?: { reason?: string },
|
|
): Promise<unknown[]>;
|
|
}
|
|
const callSemanticSearch = (b: LocalBackend, degraded?: { reason?: string }): Promise<unknown[]> =>
|
|
(b as unknown as SemanticSearchable).semanticSearch({ lbugPath: '/tmp/x' }, 'q', 5, degraded);
|
|
|
|
const stackWarns = (cap: LoggerCapture): number =>
|
|
cap
|
|
.records()
|
|
.filter(
|
|
(r) =>
|
|
typeof r.msg === 'string' &&
|
|
r.msg.includes('query:vector') &&
|
|
r.msg.includes('local embedding stack is not installed'),
|
|
).length;
|
|
|
|
describe('LocalBackend.semanticSearch — missing-stack warning (#2372)', () => {
|
|
beforeEach(() => {
|
|
executeQueryMock.mockReset().mockResolvedValue([{ cnt: 5 }]);
|
|
embedQueryMock.mockReset();
|
|
});
|
|
|
|
it('warns once with the actionable message and returns [] on a pruned stack', async () => {
|
|
embedQueryMock.mockRejectedValue(new Error(localEmbeddingStackMissingMessage()));
|
|
const backend = new LocalBackend();
|
|
const cap = _captureLogger();
|
|
const first = { reason: undefined as string | undefined };
|
|
const second = { reason: undefined as string | undefined };
|
|
try {
|
|
expect(await callSemanticSearch(backend, first)).toEqual([]);
|
|
expect(await callSemanticSearch(backend, second)).toEqual([]);
|
|
expect(stackWarns(cap)).toBe(1); // once per LocalBackend instance
|
|
expect(first.reason).toContain('local embedding stack is not installed');
|
|
expect(second.reason).toContain('local embedding stack is not installed');
|
|
} finally {
|
|
cap.restore();
|
|
}
|
|
});
|
|
|
|
it('stashes sidecar-abort text for query() warnings', async () => {
|
|
embedQueryMock.mockRejectedValue(new Error(LOCAL_EMBEDDING_SIDECAR_ABORT_LEAD));
|
|
const backend = new LocalBackend();
|
|
const cap = _captureLogger();
|
|
const degraded = { reason: undefined as string | undefined };
|
|
try {
|
|
expect(await callSemanticSearch(backend, degraded)).toEqual([]);
|
|
expect(degraded.reason).toBe(LOCAL_EMBEDDING_SIDECAR_ABORT_LEAD);
|
|
expect(
|
|
cap.records().some((r) => typeof r.msg === 'string' && r.msg.includes('sidecar aborted')),
|
|
).toBe(true);
|
|
} finally {
|
|
cap.restore();
|
|
}
|
|
});
|
|
|
|
it('stays silent for an unrelated error', async () => {
|
|
embedQueryMock.mockRejectedValue(new Error('some unrelated failure'));
|
|
const backend = new LocalBackend();
|
|
const cap = _captureLogger();
|
|
try {
|
|
expect(await callSemanticSearch(backend)).toEqual([]);
|
|
expect(stackWarns(cap)).toBe(0);
|
|
} finally {
|
|
cap.restore();
|
|
}
|
|
});
|
|
|
|
it('logs once when the index has no embedding rows (#3372)', async () => {
|
|
executeQueryMock.mockResolvedValue([{ cnt: 0 }]);
|
|
const backend = new LocalBackend();
|
|
const cap = _captureLogger();
|
|
const degraded = { reason: undefined as string | undefined };
|
|
try {
|
|
expect(await callSemanticSearch(backend, degraded)).toEqual([]);
|
|
expect(await callSemanticSearch(backend, degraded)).toEqual([]);
|
|
expect(degraded.reason).toBeUndefined();
|
|
expect(embedQueryMock).not.toHaveBeenCalled();
|
|
expect(
|
|
cap
|
|
.records()
|
|
.filter((r) => typeof r.msg === 'string' && r.msg.includes('no embedding vectors'))
|
|
.length,
|
|
).toBe(1);
|
|
} finally {
|
|
cap.restore();
|
|
}
|
|
});
|
|
|
|
it('logs once when the embedding table is missing (#3372)', async () => {
|
|
executeQueryMock.mockRejectedValue(
|
|
new Error('Binder exception: Table CodeEmbedding does not exist.'),
|
|
);
|
|
const backend = new LocalBackend();
|
|
const cap = _captureLogger();
|
|
const degraded = { reason: undefined as string | undefined };
|
|
try {
|
|
expect(await callSemanticSearch(backend, degraded)).toEqual([]);
|
|
expect(await callSemanticSearch(backend, degraded)).toEqual([]);
|
|
expect(degraded.reason).toBeUndefined();
|
|
expect(embedQueryMock).not.toHaveBeenCalled();
|
|
expect(
|
|
cap
|
|
.records()
|
|
.filter((r) => typeof r.msg === 'string' && r.msg.includes('no embedding vectors'))
|
|
.length,
|
|
).toBe(1);
|
|
} finally {
|
|
cap.restore();
|
|
}
|
|
});
|
|
});
|