Merge remote-tracking branch 'szu/cpp-scope-resolution-parity' into cpp-scope-resolution-parity

This commit is contained in:
Gergo Magyar 2026-05-13 17:14:34 +01:00
commit ce66d1e9a4
10 changed files with 167 additions and 50 deletions

View file

@ -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
View file

@ -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]]

View file

@ -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…');

View file

@ -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;
}

View file

@ -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

View file

@ -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);
};

View file

@ -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[] = [];

View file

@ -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);
});

View file

@ -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);

View file

@ -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']);
});
});