mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-24 00:51:53 +00:00
Merge remote-tracking branch 'szu/cpp-scope-resolution-parity' into cpp-scope-resolution-parity
This commit is contained in:
commit
ce66d1e9a4
10 changed files with 167 additions and 50 deletions
|
|
@ -40,8 +40,8 @@ RUN npm prune --omit=dev --prefix gitnexus
|
|||
# node:22-bookworm-slim
|
||||
FROM node:22-bookworm-slim@sha256:9f6d5975c7dca860947d3915877f85607946403fc55349f39b4bc3688448bb6e AS runtime
|
||||
|
||||
# curl for the healthcheck; git so `gitnexus` can clone repos at runtime.
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends curl git && rm -rf /var/lib/apt/lists/* \
|
||||
# curl for the healthcheck; git for cloning; ca-certificates for TLS verification.
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends curl git ca-certificates && rm -rf /var/lib/apt/lists/* \
|
||||
&& rm -rf /usr/local/lib/node_modules/npm \
|
||||
&& rm -rf /usr/local/lib/node_modules/corepack \
|
||||
&& rm -f /usr/local/bin/npm /usr/local/bin/npx /usr/local/bin/corepack
|
||||
|
|
|
|||
6
eval/uv.lock
generated
6
eval/uv.lock
generated
|
|
@ -2278,11 +2278,11 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "urllib3"
|
||||
version = "2.6.3"
|
||||
version = "2.7.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
|
|||
|
|
@ -21,11 +21,15 @@ const SHARED_DEST = path.join(DIST, '_shared');
|
|||
|
||||
// ── 1. Build gitnexus-shared ───────────────────────────────────────
|
||||
console.log('[build] compiling gitnexus-shared…');
|
||||
execSync('npx tsc', { cwd: SHARED_ROOT, stdio: 'inherit', timeout: 120_000 });
|
||||
const tscCmd =
|
||||
process.platform === 'win32'
|
||||
? path.join('node_modules', '.bin', 'tsc.cmd')
|
||||
: path.join('node_modules', '.bin', 'tsc');
|
||||
execSync(tscCmd, { cwd: SHARED_ROOT, stdio: 'inherit', timeout: 120_000 });
|
||||
|
||||
// ── 2. Build gitnexus ──────────────────────────────────────────────
|
||||
console.log('[build] compiling gitnexus…');
|
||||
execSync('npx tsc', { cwd: ROOT, stdio: 'inherit', timeout: 120_000 });
|
||||
execSync(tscCmd, { cwd: ROOT, stdio: 'inherit', timeout: 120_000 });
|
||||
|
||||
// ── 3. Copy shared dist ────────────────────────────────────────────
|
||||
console.log('[build] copying shared module into dist/_shared…');
|
||||
|
|
|
|||
|
|
@ -68,7 +68,10 @@ export function clearFileLocalNames(): void {
|
|||
*/
|
||||
export function populateCppNonGloballyVisible(parsed: {
|
||||
readonly filePath: string;
|
||||
readonly scopes: readonly { readonly kind: string; readonly ownedDefs: readonly { readonly nodeId: string }[] }[];
|
||||
readonly scopes: readonly {
|
||||
readonly kind: string;
|
||||
readonly ownedDefs: readonly { readonly nodeId: string }[];
|
||||
}[];
|
||||
}): void {
|
||||
let set = nonGloballyVisibleNodeIds.get(parsed.filePath);
|
||||
if (set === undefined) {
|
||||
|
|
@ -182,7 +185,10 @@ export function expandCppWildcardNames(
|
|||
// include (preserves prior behavior for any def whose structural
|
||||
// ownership wasn't recorded in `Scope.ownedDefs`).
|
||||
const ownerScope = ownerScopeByNodeId.get(def.nodeId);
|
||||
if (ownerScope !== undefined && (ownerScope.kind === 'Namespace' || ownerScope.kind === 'Class')) {
|
||||
if (
|
||||
ownerScope !== undefined &&
|
||||
(ownerScope.kind === 'Namespace' || ownerScope.kind === 'Class')
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -51,7 +51,10 @@ import {
|
|||
import { tryEmitEdge } from '../graph-bridge/edges.js';
|
||||
import { resolveCompoundReceiverClass } from '../passes/compound-receiver.js';
|
||||
import { resolveDefGraphId } from '../graph-bridge/ids.js';
|
||||
import { narrowOverloadCandidates, isOverloadAmbiguousAfterNormalization } from './overload-narrowing.js';
|
||||
import {
|
||||
narrowOverloadCandidates,
|
||||
isOverloadAmbiguousAfterNormalization,
|
||||
} from './overload-narrowing.js';
|
||||
|
||||
/** Subset of `ScopeResolver` consumed by this pass. Accepting the
|
||||
* subset rather than the full provider keeps tests and partial
|
||||
|
|
|
|||
|
|
@ -50,9 +50,15 @@ export const mergeWithRRF = (
|
|||
): HybridSearchResult[] => {
|
||||
const merged = new Map<string, HybridSearchResult>();
|
||||
|
||||
// Guard against undefined/null inputs (#1489) — when FTS is unavailable
|
||||
// in the MCP process, bm25Results can arrive as undefined and the
|
||||
// for-loop would throw "bm25Results is not iterable".
|
||||
const safeBm25 = bm25Results ?? [];
|
||||
const safeSemantic = semanticResults ?? [];
|
||||
|
||||
// Process BM25 results
|
||||
for (let i = 0; i < bm25Results.length; i++) {
|
||||
const r = bm25Results[i];
|
||||
for (let i = 0; i < safeBm25.length; i++) {
|
||||
const r = safeBm25[i];
|
||||
const rrfScore = 1 / (RRF_K + i + 1); // i+1 because rank starts at 1
|
||||
|
||||
merged.set(r.filePath, {
|
||||
|
|
@ -65,8 +71,8 @@ export const mergeWithRRF = (
|
|||
}
|
||||
|
||||
// Process semantic results and merge
|
||||
for (let i = 0; i < semanticResults.length; i++) {
|
||||
const r = semanticResults[i];
|
||||
for (let i = 0; i < safeSemantic.length; i++) {
|
||||
const r = safeSemantic[i];
|
||||
const rrfScore = 1 / (RRF_K + i + 1);
|
||||
|
||||
const existing = merged.get(r.filePath);
|
||||
|
|
@ -149,6 +155,9 @@ export const formatHybridResults = (results: HybridSearchResult[]): string => {
|
|||
* Execute BM25 + semantic search and merge with RRF.
|
||||
* Uses LadybugDB FTS for always-fresh BM25 results (no cached data).
|
||||
* The semanticSearch function is injected to keep this module environment-agnostic.
|
||||
*
|
||||
* When FTS is unavailable (e.g. read-only MCP connection, missing indexes),
|
||||
* falls back to semantic-only results instead of crashing (#1489).
|
||||
*/
|
||||
export const hybridSearch = async (
|
||||
query: string,
|
||||
|
|
@ -160,8 +169,16 @@ export const hybridSearch = async (
|
|||
k?: number,
|
||||
) => Promise<SemanticSearchResult[]>,
|
||||
): Promise<HybridSearchResult[]> => {
|
||||
// Use LadybugDB FTS for always-fresh BM25 results
|
||||
const { results: bm25Results } = await searchFTSFromLbug(query, limit);
|
||||
// Use LadybugDB FTS for always-fresh BM25 results.
|
||||
// If FTS fails (e.g. extension not loaded in MCP process), fall back to
|
||||
// semantic-only search instead of crashing with "bm25Results is not iterable".
|
||||
let bm25Results: BM25SearchResult[] = [];
|
||||
try {
|
||||
const ftsResponse = await searchFTSFromLbug(query, limit);
|
||||
bm25Results = ftsResponse?.results ?? [];
|
||||
} catch {
|
||||
// FTS unavailable — continue with semantic-only search
|
||||
}
|
||||
const semanticResults = await semanticSearch(executeQuery, query, limit);
|
||||
return mergeWithRRF(bm25Results, semanticResults, limit);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -755,8 +755,10 @@ export class LocalBackend {
|
|||
timer.time('vector', this.semanticSearch(repo, searchQuery, searchLimit)),
|
||||
]);
|
||||
|
||||
const bm25Results = bm25SearchResult.results;
|
||||
const ftsUsed = bm25SearchResult.ftsUsed;
|
||||
// Guard against undefined results (#1489) — when FTS is entirely
|
||||
// unavailable the search helper may return an unexpected shape.
|
||||
const bm25Results = bm25SearchResult?.results ?? [];
|
||||
const ftsUsed = bm25SearchResult?.ftsUsed ?? false;
|
||||
|
||||
// Merge via reciprocal rank fusion
|
||||
timer.start('merge');
|
||||
|
|
@ -774,8 +776,9 @@ export class LocalBackend {
|
|||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < semanticResults.length; i++) {
|
||||
const result = semanticResults[i];
|
||||
const safeSemanticResults = semanticResults ?? [];
|
||||
for (let i = 0; i < safeSemanticResults.length; i++) {
|
||||
const result = safeSemanticResults[i];
|
||||
const key = result.nodeId || result.filePath;
|
||||
const rrfScore = 1 / (60 + i);
|
||||
const existing = scoreMap.get(key);
|
||||
|
|
@ -992,7 +995,17 @@ export class LocalBackend {
|
|||
query: string,
|
||||
limit: number,
|
||||
): Promise<{ results: any[]; ftsUsed: boolean }> {
|
||||
const { searchFTSFromLbug } = await import('../../core/search/bm25-index.js');
|
||||
let searchFTSFromLbug;
|
||||
try {
|
||||
({ searchFTSFromLbug } = await import('../../core/search/bm25-index.js'));
|
||||
} catch (err: any) {
|
||||
// Module import can fail in sandboxed MCP contexts (#1489)
|
||||
logger.warn(
|
||||
{ err: err?.message },
|
||||
'GitNexus: bm25-index.js import failed — falling back to semantic-only',
|
||||
);
|
||||
return { results: [], ftsUsed: false };
|
||||
}
|
||||
let ftsResponse;
|
||||
try {
|
||||
ftsResponse = await searchFTSFromLbug(query, limit, repo.id);
|
||||
|
|
@ -1004,8 +1017,10 @@ export class LocalBackend {
|
|||
return { results: [], ftsUsed: false };
|
||||
}
|
||||
|
||||
const bm25Results = ftsResponse.results;
|
||||
const ftsUsed = ftsResponse.ftsAvailable;
|
||||
// Guard against unexpected response shape (#1489) — ftsResponse.results
|
||||
// could be undefined when the FTS extension is unavailable in the MCP process.
|
||||
const bm25Results = ftsResponse?.results ?? [];
|
||||
const ftsUsed = ftsResponse?.ftsAvailable ?? false;
|
||||
|
||||
const results: any[] = [];
|
||||
|
||||
|
|
|
|||
|
|
@ -1597,10 +1597,7 @@ describe('C++ include does not leak class methods', () => {
|
|||
let result: PipelineResult;
|
||||
|
||||
beforeAll(async () => {
|
||||
result = await runPipelineFromRepo(
|
||||
path.join(FIXTURES, 'cpp-include-no-class-leak'),
|
||||
() => {},
|
||||
);
|
||||
result = await runPipelineFromRepo(path.join(FIXTURES, 'cpp-include-no-class-leak'), () => {});
|
||||
}, 60000);
|
||||
|
||||
it('does NOT resolve unqualified save() to User::save via #include', () => {
|
||||
|
|
@ -1674,10 +1671,7 @@ describe('C++ ambiguous integer-width overloads', () => {
|
|||
let result: PipelineResult;
|
||||
|
||||
beforeAll(async () => {
|
||||
result = await runPipelineFromRepo(
|
||||
path.join(FIXTURES, 'cpp-overload-int-long'),
|
||||
() => {},
|
||||
);
|
||||
result = await runPipelineFromRepo(path.join(FIXTURES, 'cpp-overload-int-long'), () => {});
|
||||
}, 60000);
|
||||
|
||||
it('emits zero CALLS edges when process(int)/process(long) collide after normalization', () => {
|
||||
|
|
@ -1700,19 +1694,14 @@ describe('C++ anonymous namespace cross-file exclusion (integration)', () => {
|
|||
let result: PipelineResult;
|
||||
|
||||
beforeAll(async () => {
|
||||
result = await runPipelineFromRepo(
|
||||
path.join(FIXTURES, 'cpp-anon-ns-cross-file'),
|
||||
() => {},
|
||||
);
|
||||
result = await runPipelineFromRepo(path.join(FIXTURES, 'cpp-anon-ns-cross-file'), () => {});
|
||||
}, 60000);
|
||||
|
||||
it('caller.cpp::run -> worker does NOT target helper.cpp anonymous-namespace worker', () => {
|
||||
const calls = getRelationships(result, 'CALLS');
|
||||
const crossFileLeak = calls.filter(
|
||||
(c) =>
|
||||
c.source === 'run' &&
|
||||
c.target === 'worker' &&
|
||||
c.targetFilePath?.includes('helper.cpp'),
|
||||
c.source === 'run' && c.target === 'worker' && c.targetFilePath?.includes('helper.cpp'),
|
||||
);
|
||||
expect(crossFileLeak.length).toBe(0);
|
||||
});
|
||||
|
|
@ -1743,9 +1732,7 @@ describe('C++ anonymous namespace state-isolation guard', () => {
|
|||
const countLeak = (r: PipelineResult): number =>
|
||||
getRelationships(r, 'CALLS').filter(
|
||||
(c) =>
|
||||
c.source === 'run' &&
|
||||
c.target === 'worker' &&
|
||||
c.targetFilePath?.includes('helper.cpp'),
|
||||
c.source === 'run' && c.target === 'worker' && c.targetFilePath?.includes('helper.cpp'),
|
||||
).length;
|
||||
expect(countLeak(r1)).toBe(0);
|
||||
expect(countLeak(r2)).toBe(0);
|
||||
|
|
@ -1800,18 +1787,14 @@ describe('C++ using-namespace std smoke test', () => {
|
|||
|
||||
it('resolves the project call (positive guard against vacuous pass)', () => {
|
||||
const calls = getRelationships(result, 'CALLS');
|
||||
const projectCalls = calls.filter(
|
||||
(c) => c.source === 'run' && c.target === 'project_helper',
|
||||
);
|
||||
const projectCalls = calls.filter((c) => c.source === 'run' && c.target === 'project_helper');
|
||||
expect(projectCalls.length).toBe(1);
|
||||
});
|
||||
|
||||
it('does NOT leak unqualified bindings for shim STL symbols', () => {
|
||||
const calls = getRelationships(result, 'CALLS');
|
||||
const stlLeaks = calls.filter(
|
||||
(c) =>
|
||||
c.source === 'run' &&
|
||||
(c.target === 'cout_write' || c.target === 'println'),
|
||||
(c) => c.source === 'run' && (c.target === 'cout_write' || c.target === 'println'),
|
||||
);
|
||||
expect(stlLeaks.length).toBe(0);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -216,6 +216,19 @@ describe('LocalBackend.callTool', () => {
|
|||
expect(result).not.toHaveProperty('warning');
|
||||
});
|
||||
|
||||
it('does not crash when searchFTSFromLbug throws (#1489)', async () => {
|
||||
const { searchFTSFromLbug } = await import('../../src/core/search/bm25-index.js');
|
||||
vi.mocked(searchFTSFromLbug).mockRejectedValueOnce(new Error('bm25Results is not iterable'));
|
||||
(executeParameterized as any).mockResolvedValue([]);
|
||||
|
||||
const result = await backend.callTool('query', { query: 'auth' });
|
||||
|
||||
// Should still return a valid result shape (semantic-only fallback)
|
||||
expect(result).toHaveProperty('processes');
|
||||
expect(result).toHaveProperty('definitions');
|
||||
expect(result).not.toHaveProperty('error');
|
||||
});
|
||||
|
||||
it('skips vector index query when VECTOR is unsupported by the platform', async () => {
|
||||
const cap = _captureLogger();
|
||||
platformMocks.isVectorExtensionSupportedByPlatform.mockReturnValue(false);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
/**
|
||||
* P1 Unit Tests: Hybrid Search (mergeWithRRF)
|
||||
* P1 Unit Tests: Hybrid Search (mergeWithRRF + hybridSearch)
|
||||
*
|
||||
* Tests: mergeWithRRF from hybrid-search.ts
|
||||
* - BM25-only merge
|
||||
|
|
@ -7,12 +7,20 @@
|
|||
* - Combined ranking
|
||||
* - Limit parameter
|
||||
* - Empty inputs
|
||||
* - Undefined/null inputs (#1489)
|
||||
*
|
||||
* Tests: hybridSearch fallback when FTS unavailable (#1489)
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { mergeWithRRF } from '../../src/core/search/hybrid-search.js';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { mergeWithRRF, hybridSearch } from '../../src/core/search/hybrid-search.js';
|
||||
import type { BM25SearchResult } from '../../src/core/search/bm25-index.js';
|
||||
import type { SemanticSearchResult } from '../../src/core/embeddings/types.js';
|
||||
|
||||
vi.mock('../../src/core/search/bm25-index.js', async (importOriginal) => {
|
||||
const actual = (await importOriginal()) as any;
|
||||
return { ...actual, searchFTSFromLbug: vi.fn() };
|
||||
});
|
||||
|
||||
let bm25Rank = 0;
|
||||
function makeBM25(filePath: string, score: number): BM25SearchResult {
|
||||
return { filePath, score, rank: ++bm25Rank };
|
||||
|
|
@ -123,4 +131,72 @@ describe('mergeWithRRF', () => {
|
|||
expect(result[0].bm25Score).toBe(15);
|
||||
expect(result[0].semanticScore).toBeCloseTo(0.7); // 1 - distance
|
||||
});
|
||||
|
||||
// Regression: #1489 — bm25Results is not iterable when FTS unavailable
|
||||
it('does not crash when bm25Results is undefined (#1489)', () => {
|
||||
const semantic: SemanticSearchResult[] = [makeSemantic('src/a.ts', 0.1)];
|
||||
// Force undefined to simulate the crash path where FTS returns unexpected shape
|
||||
const result = mergeWithRRF(undefined as any, semantic);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].filePath).toBe('src/a.ts');
|
||||
expect(result[0].sources).toEqual(['semantic']);
|
||||
});
|
||||
|
||||
it('does not crash when semanticResults is undefined (#1489)', () => {
|
||||
const bm25: BM25SearchResult[] = [makeBM25('src/a.ts', 10)];
|
||||
const result = mergeWithRRF(bm25, undefined as any);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].filePath).toBe('src/a.ts');
|
||||
expect(result[0].sources).toEqual(['bm25']);
|
||||
});
|
||||
|
||||
it('does not crash when both inputs are undefined (#1489)', () => {
|
||||
const result = mergeWithRRF(undefined as any, undefined as any);
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// Regression: #1489 — hybridSearch must not crash when FTS is unavailable
|
||||
describe('hybridSearch — FTS failure fallback (#1489)', () => {
|
||||
it('falls back to semantic-only when searchFTSFromLbug throws', async () => {
|
||||
const { searchFTSFromLbug } = await import('../../src/core/search/bm25-index.js');
|
||||
vi.mocked(searchFTSFromLbug).mockRejectedValueOnce(new Error('bm25Results is not iterable'));
|
||||
|
||||
const mockExecuteQuery = vi.fn().mockResolvedValue([]);
|
||||
const mockSemanticSearch = vi
|
||||
.fn()
|
||||
.mockResolvedValue([makeSemantic('src/semantic-hit.ts', 0.15)]);
|
||||
|
||||
const results = await hybridSearch('test query', 10, mockExecuteQuery, mockSemanticSearch);
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].filePath).toBe('src/semantic-hit.ts');
|
||||
expect(results[0].sources).toEqual(['semantic']);
|
||||
});
|
||||
|
||||
it('returns empty when both FTS and semantic return nothing', async () => {
|
||||
const { searchFTSFromLbug } = await import('../../src/core/search/bm25-index.js');
|
||||
vi.mocked(searchFTSFromLbug).mockRejectedValueOnce(new Error('FTS unavailable'));
|
||||
|
||||
const mockExecuteQuery = vi.fn().mockResolvedValue([]);
|
||||
const mockSemanticSearch = vi.fn().mockResolvedValue([]);
|
||||
|
||||
const results = await hybridSearch('test query', 10, mockExecuteQuery, mockSemanticSearch);
|
||||
expect(results).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('works normally when FTS succeeds', async () => {
|
||||
const { searchFTSFromLbug } = await import('../../src/core/search/bm25-index.js');
|
||||
vi.mocked(searchFTSFromLbug).mockResolvedValueOnce({
|
||||
results: [{ filePath: 'src/fts-hit.ts', score: 5, rank: 1 }],
|
||||
ftsAvailable: true,
|
||||
});
|
||||
|
||||
const mockExecuteQuery = vi.fn().mockResolvedValue([]);
|
||||
const mockSemanticSearch = vi.fn().mockResolvedValue([]);
|
||||
|
||||
const results = await hybridSearch('test query', 10, mockExecuteQuery, mockSemanticSearch);
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].filePath).toBe('src/fts-hit.ts');
|
||||
expect(results[0].sources).toEqual(['bm25']);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue