From 1a5d88391cf459911c2607523ce9786642053748 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Magyar?= Date: Sun, 4 Oct 2026 10:52:01 +0100 Subject: [PATCH] fix(mcp): reject corrupt impact and context identities (#3466) --- gitnexus/src/mcp/local/aop-metadata.ts | 27 +- gitnexus/src/mcp/local/local-backend.ts | 222 ++++- gitnexus/src/mcp/local/pdg-impact.ts | 27 +- .../src/mcp/local/query-result-integrity.ts | 57 ++ .../impact-context-integrity.test.ts | 758 ++++++++++++++++ .../unit/calltool-dispatch-id-bridge.test.ts | 17 +- gitnexus/test/unit/calltool-dispatch.test.ts | 104 +-- .../unit/impact-batching-grouping.test.ts | 21 + .../unit/impact-context-integrity.test.ts | 847 ++++++++++++++++++ gitnexus/test/unit/impact-pagination.test.ts | 2 + .../test/unit/impact-route-enrichment.test.ts | 5 +- gitnexus/test/unit/pdg-impact-engine.test.ts | 83 ++ gitnexus/vitest.config.ts | 2 + 13 files changed, 2080 insertions(+), 92 deletions(-) create mode 100644 gitnexus/src/mcp/local/query-result-integrity.ts create mode 100644 gitnexus/test/integration/impact-context-integrity.test.ts create mode 100644 gitnexus/test/unit/impact-context-integrity.test.ts diff --git a/gitnexus/src/mcp/local/aop-metadata.ts b/gitnexus/src/mcp/local/aop-metadata.ts index a3506d9c5..d5d41ab02 100644 --- a/gitnexus/src/mcp/local/aop-metadata.ts +++ b/gitnexus/src/mcp/local/aop-metadata.ts @@ -1,4 +1,9 @@ import { executeParameterized } from '../../core/lbug/pool-adapter.js'; +import { + assertSymbolIdentity, + assertIdentityFields, + rethrowSymbolIdentityError, +} from './query-result-integrity.js'; import { decodeSpringAopReason, type SpringAopReason, @@ -151,6 +156,7 @@ const DETERMINISTIC_RELATIONSHIP_ORDER = 'ORDER BY sourceId, targetId, reason, s * shared decoder. Other DECLARES edges (for example Spring Bean factories) * and malformed/forward-version evidence are ignored. Query failures are * fail-soft because older or partially upgraded indexes must remain readable. + * Corrupt identities propagate to the context/impact integrity error boundary. */ export async function querySpringAopMetadata( lbugPath: string, @@ -204,6 +210,24 @@ export async function querySpringAopMetadata( ), ]); + for (const rows of [ + outgoingAdviceRows, + incomingAdviceRows, + outgoingPointcutRows, + incomingPointcutRows, + ]) { + for (const row of rows) { + assertSymbolIdentity(readRowValue(row, 'sourceId', 0)); + assertSymbolIdentity(readRowValue(row, 'targetId', 3)); + assertIdentityFields( + readRowValue(row, 'sourceName', 1), + readRowValue(row, 'sourceFilePath', 2), + readRowValue(row, 'targetName', 4), + readRowValue(row, 'targetFilePath', 5), + ); + } + } + const behaviors: SpringAopBehaviorMetadata[] = []; const advices: SpringAopAdviceMetadata[] = []; const resolvedPointcuts: SpringAopResolvedPointcutMetadata[] = []; @@ -346,7 +370,8 @@ export async function querySpringAopMetadata( resolvedPointcuts: dedupedResolvedPointcuts, unresolvedPointcuts: dedupedPointcuts, }; - } catch { + } catch (error) { + rethrowSymbolIdentityError(error); return undefined; } } diff --git a/gitnexus/src/mcp/local/local-backend.ts b/gitnexus/src/mcp/local/local-backend.ts index cb8546543..e5dc607f1 100644 --- a/gitnexus/src/mcp/local/local-backend.ts +++ b/gitnexus/src/mcp/local/local-backend.ts @@ -28,6 +28,15 @@ import { } from '../../core/lbug/pool-adapter.js'; import { queryClassBeanMetadata } from './bean-metadata.js'; import { querySpringAopMetadata } from './aop-metadata.js'; +import { + SYMBOL_IDENTITY_RECOVERY_SUGGESTION, + SymbolIdentityError, + assertSymbolIdentity, + queryRowValue, + assertIdentityFields, + assertQueryIdentity, + rethrowSymbolIdentityError, +} from './query-result-integrity.js'; import { queryConvexDispatchMetadata } from './convex-metadata.js'; import { isValidQueryParams } from '../../core/lbug/query-params.js'; import { toDisplayLine } from './line-display.js'; @@ -324,25 +333,67 @@ function nonBlankUid(value: unknown): string | undefined { return typeof value === 'string' ? value.trim() || undefined : undefined; } -const SYMBOL_IDENTITY_RECOVERY_SUGGESTION = - 'Run gitnexus analyze --force from the affected repository root to rebuild the index.'; +function assertSymbolRowIdentity(row: unknown): void { + assertQueryIdentity(row, 'id', 0, [ + ['name', 1], + ['type', 2], + ['filePath', 3], + ]); +} -class SymbolIdentityError extends Error { - constructor() { - super('The index returned an invalid symbol identity. ' + SYMBOL_IDENTITY_RECOVERY_SUGGESTION); - this.name = 'SymbolIdentityError'; +function assertContextRefs(rows: unknown[]): void { + for (const row of rows) { + assertQueryIdentity(row, 'uid', 1, [ + ['name', 2], + ['filePath', 3], + ['kind', 4], + ]); + assertSymbolIdentity(queryRowValue(row, 'relType', 0)); } } -/** Validate database identities before using them as graph traversal anchors. */ -function assertSymbolIdentity(id: unknown, expectedUid?: string): asserts id is string { - if ( - typeof id !== 'string' || - !id.trim() || - id.includes('\0') || - (expectedUid !== undefined && id !== expectedUid) - ) { - throw new SymbolIdentityError(); +const RESPONSE_IDENTITY_FIELDS = new Set([ + 'id', + 'uid', + 'name', + 'filePath', + 'label', + 'kind', + 'type', + 'relationType', + 'processType', + 'url', + 'method', + 'sourceId', + 'targetId', + 'symbolId', + 'symbolName', + 'symbolFilePath', + 'adviceId', + 'adviceName', + 'adviceFilePath', + 'advisedId', + 'advisedName', + 'advisedFilePath', + 'evidenceId', +]); + +/** Cover nested additive identity fields while leaving source/metadata text alone. */ +function assertResponseIdentities(value: unknown): void { + if (Array.isArray(value)) { + for (const item of value) assertResponseIdentities(item); + } else if (value !== null && typeof value === 'object') { + for (const [key, field] of Object.entries(value)) { + if (key === 'seedBlocks' || key === 'reachableBlocks' || key === 'intraReachableBlocks') { + if (!Array.isArray(field)) throw new SymbolIdentityError(); + for (const id of field) assertSymbolIdentity(id); + continue; + } + if (RESPONSE_IDENTITY_FIELDS.has(key)) assertIdentityFields(field); + if (key !== 'content' && key !== 'methodMetadata' && key !== 'bean') { + assertResponseIdentities(field); + } + } } } @@ -4393,9 +4444,10 @@ export class LocalBackend { * "unknown kind" and, worse, makes the `kind` disambiguation hint unable to * filter it out (#2687). * - * Failures are swallowed: label enrichment is an optimisation for + * Ordinary query failures are swallowed: label enrichment is an optimisation for * downstream scoring and #480 Class/Interface BFS seeding; if it fails * the symbol still resolves, just without the kind-priority bonus. + * Corrupt identities propagate to the context/impact error envelope. */ private async enrichCandidateLabels( repo: RepoHandle, @@ -4429,6 +4481,7 @@ export class LocalBackend { ); const labelById = new Map(); for (const r of rows as any[]) { + assertQueryIdentity(r, 'id', 0, [['label', 1]]); const id = (r.id ?? r[0]) as string; const label = (r.label ?? r[1]) as string; if (id && label && !labelById.has(id)) labelById.set(id, label); @@ -4436,7 +4489,8 @@ export class LocalBackend { for (const c of candidates) { if (c.type === '' && labelById.has(c.id)) c.type = labelById.get(c.id) as string; } - } catch { + } catch (error) { + rethrowSymbolIdentityError(error); /* best-effort — downstream resolvers still work without the label */ } } @@ -4561,6 +4615,7 @@ export class LocalBackend { { uid }, ); if (rows.length === 0) return { kind: 'not_found' }; + assertSymbolRowIdentity(rows[0]); const r = rows[0] as any; const symbol = { id: (r.id ?? r[0]) as string, @@ -4695,6 +4750,10 @@ export class LocalBackend { if (rows.length === 0) return { kind: 'not_found' }; + // Reject every raw candidate before narrowing/scoring can hide a corrupt row. + for (const row of rows) { + assertSymbolRowIdentity(row); + } // Normalise row shape across object / tuple returns from LadybugDB. let normalized = rows.map((r: any) => ({ id: (r.id ?? r[0]) as string, @@ -4705,10 +4764,6 @@ export class LocalBackend { endLine: (r.endLine ?? r[5]) as number, ...(include_content ? { content: (r.content ?? r[6]) as string | undefined } : {}), })); - // Reject the whole result before narrowing or scoring: dropping a corrupt - // candidate could make an unrelated surviving symbol look unambiguous. - for (const candidate of normalized) assertSymbolIdentity(candidate.id); - // An exact File path wins over anchored suffix candidates. Without this, // `lib/a.ts` and `src/lib/a.ts` both score as File candidates and turn an // otherwise unambiguous exact target into `ambiguous` (#3084 review P2). @@ -4858,7 +4913,9 @@ export class LocalBackend { }, ): Promise { try { - return await this._contextImpl(repo, params); + const result = await this._contextImpl(repo, params); + if (!result.error) assertResponseIdentities(result); + return result; } catch (err: any) { const msg = (err instanceof Error ? err.message : String(err)) || 'Context query failed'; if (err instanceof SymbolIdentityError) { @@ -4978,6 +5035,8 @@ export class LocalBackend { { symId }, ), ]); + assertContextRefs(incomingRows); + assertContextRefs(incomingAdvisedRows); incomingRows.push(...incomingAdvisedRows); let typedPropertyRows: any[] = []; @@ -5082,6 +5141,16 @@ export class LocalBackend { }, ), ]); + assertContextRefs(ctorIncoming); + assertContextRefs(fileIncoming); + assertContextRefs(typedPropertyIncoming); + for (const row of typedProperties) { + assertQueryIdentity(row, 'uid', 0, [ + ['name', 1], + ['filePath', 2], + ['kind', 3], + ]); + } typedPropertyRows = typedProperties; // Deduplicate by (relType, uid) — a caller can have multiple relation @@ -5098,6 +5167,7 @@ export class LocalBackend { } } } catch (e) { + rethrowSymbolIdentityError(e); logQueryError('context:class-incoming-expansion', e); } } @@ -5128,6 +5198,8 @@ export class LocalBackend { { symId }, ), ]); + assertContextRefs(outgoingRows); + assertContextRefs(outgoingAdvisedRows); outgoingRows.push(...outgoingAdvisedRows); // Process participation. @@ -5151,7 +5223,14 @@ export class LocalBackend { `, { symId }, ); + for (const row of processRows) { + assertQueryIdentity(row, 'pid', 0, [ + ['label', 1], + ['entryPointId', 4], + ]); + } } catch (e) { + rethrowSymbolIdentityError(e); logQueryError('context:process-participation', e); } @@ -5188,6 +5267,7 @@ export class LocalBackend { // (GET/POST pair). URL-only dedup would drop the second endpoint. const seenRoutes = new Set(); for (const r of routeRows) { + assertIdentityFields(queryRowValue(r, 'url', 0), queryRowValue(r, 'method', 1)); const url = r.url ?? r[0]; const method = r.method ?? r[1]; const dedupKey = routeEnrichmentKey(method ? String(method) : undefined, url); @@ -5197,7 +5277,8 @@ export class LocalBackend { } } } catch (e) { - // Best-effort enrichment — never fail the context call. + rethrowSymbolIdentityError(e); + // Ordinary query failures leave this best-effort enrichment unavailable. logQueryError('context:route-lookup', e); } @@ -5247,6 +5328,7 @@ export class LocalBackend { ); const beanMetadataPromise = queryClassBeanMetadata(repo.lbugPath, symId, epistemicSymType); const aopMetadataPromise = querySpringAopMetadata(repo.lbugPath, symId, epistemicSymType); + void aopMetadataPromise.catch(() => undefined); // R3-1. A `Property` whose name the analyzer declined to link — because // every definition of it lives in another language — otherwise returns an @@ -5341,6 +5423,7 @@ export class LocalBackend { try { chain = await this._computeContextChain(repo, symId, requestedDepth); } catch (e) { + rethrowSymbolIdentityError(e); logQueryError('context:chain-bfs', e); } } @@ -5381,8 +5464,8 @@ export class LocalBackend { processes: processRows.map((r: any) => ({ id: r.pid || r[0], name: r.label || r[1], - step_index: r.step || r[2], - step_count: r.stepCount || r[3], + step_index: r.step ?? r[2], + step_count: r.stepCount ?? r[3], })), }; } @@ -5463,6 +5546,13 @@ export class LocalBackend { visited: Array.from(visited), }); if (rows.length === 0) return { nextFrontier: [] }; + for (const row of rows) { + assertQueryIdentity(row, 'uid', 0, [ + ['name', 1], + ['filePath', 2], + ['kind', 3], + ]); + } // MATCH is one row per CALLS edge. Cypher `WITH DISTINCT` applies // LIMIT 50 to unique neighbors; this second pass still collapses // twins if a driver/engine ever returns duplicate rows. @@ -5482,6 +5572,7 @@ export class LocalBackend { for (const r of fresh) visited.add(r.uid); return { nodes, nextFrontier: fresh.map((r: any) => r.uid) }; } catch (e) { + rethrowSymbolIdentityError(e); logQueryError(logLabel, e); return { nextFrontier: [] }; } @@ -7163,7 +7254,9 @@ export class LocalBackend { private async impact(repo: RepoHandle, params: ImpactParams): Promise { try { - return await this._impactImpl(repo, params); + const result = await this._impactImpl(repo, params); + if (!result.error) assertResponseIdentities(result); + return result; } catch (err: any) { // Return structured error instead of crashing (#321) const message = @@ -7481,6 +7574,7 @@ export class LocalBackend { } catch (e) { probeFailed = true; candidateProbeFailed = true; + rethrowSymbolIdentityError(e); logQueryError('impact:ambiguous-candidate', e); } return { @@ -7738,6 +7832,7 @@ export class LocalBackend { }); return composeUnifiedPdgImpactResult(pdgResult, interproceduralResult); } catch (e) { + rethrowSymbolIdentityError(e); logQueryError('impact:pdg-interprocedural-reach', e); return composeUnifiedPdgImpactResult(pdgResult, null, e); } @@ -7775,10 +7870,12 @@ export class LocalBackend { { ids: blockIds }, ); for (const r of rows as any[]) { + assertIdentityFields(r.callees ?? r[0]); const raw = String(r.callees ?? r[0] ?? ''); for (const n of raw.split(' ')) if (n) names.add(n); } } catch (e) { + rethrowSymbolIdentityError(e); logQueryError('impact:pdg-slice-callees', e); } return names; @@ -7814,6 +7911,7 @@ export class LocalBackend { for (const id of splitCalleeIds(r.calleeIds ?? r[0])) ids.add(id); } } catch (e) { + rethrowSymbolIdentityError(e); logQueryError('impact:pdg-slice-callee-ids', e); } return ids; @@ -7967,7 +8065,10 @@ export class LocalBackend { ORDER BY id LIMIT 25`, { symId, heritage: HERITAGE_TYPES }, - ).catch(() => []); + ).catch((error) => { + rethrowSymbolIdentityError(error); + return []; + }); const undecidedSummary = meta?.undecidedInterfaceSatisfaction; const undecidedDrops = undecidedSummary === undefined @@ -8006,6 +8107,10 @@ export class LocalBackend { } const ifaceRows = await interfaceRowsPromise; for (const r of ifaceRows) { + assertQueryIdentity(r, 'id', 0, [ + ['name', 1], + ['label', 2], + ]); const id = (r.id ?? r[0]) as string; if (id && !boundary.has(id)) { boundary.set(id, { @@ -8031,7 +8136,10 @@ export class LocalBackend { WHERE iface.id = $ifaceId AND r.type IN $types RETURN COUNT(DISTINCT other.id) AS cnt`, { ifaceId, types }, - ).catch(() => []); + ).catch((error) => { + rethrowSymbolIdentityError(error); + return []; + }); const cnt = rows.length > 0 ? Number((rows[0] as any).cnt ?? (rows[0] as any)[0] ?? 0) : 0; m.set(ifaceId, cnt); @@ -8090,7 +8198,8 @@ export class LocalBackend { callableValueReferences: droppedBoundaries.callableValueReferences, }, }; - } catch { + } catch (error) { + rethrowSymbolIdentityError(error); // Never let the heritage probe's failure suppress a drop we already know // about — the whole point is that silence must not read as certainty. return epistemicFrom(droppedBoundaries); @@ -8182,6 +8291,7 @@ export class LocalBackend { `Impact target '${sym.name || sym[1] || '?'}' resolved without a node id; refusing to report a blast radius`, ); } + assertSymbolRowIdentity(sym); // #1858 — kick off the epistemic boundary probe concurrently with the BFS. // It depends only on symId/symType/symName (all known now) and touches no @@ -8217,6 +8327,7 @@ export class LocalBackend { opts.skipEpistemic || summaryOnly ? Promise.resolve(undefined) : querySpringAopMetadata(repo.lbugPath, symId, symType); + void aopMetadataPromise.catch(() => undefined); const impacted: any[] = []; const visited = new Set([symId]); const pdgBridgeEvidenceById = new Map(); @@ -8261,6 +8372,7 @@ export class LocalBackend { ]); for (const r of ctorRows) { + assertSymbolRowIdentity(r); const rid = r.id || r[0]; if (rid && !visited.has(rid)) { visited.add(rid); @@ -8268,6 +8380,7 @@ export class LocalBackend { } } for (const r of fileRows) { + assertSymbolRowIdentity(r); const rid = r.id || r[0]; if (rid && !visited.has(rid)) { visited.add(rid); @@ -8292,6 +8405,7 @@ export class LocalBackend { ); for (const r of typedPropertyRows) { + assertSymbolRowIdentity(r); const rid = r.id || r[0]; if (rid && !visited.has(rid)) { visited.add(rid); @@ -8299,6 +8413,7 @@ export class LocalBackend { } } } catch (e) { + rethrowSymbolIdentityError(e); logQueryError('impact:class-node-expansion', e); traversalComplete = false; } @@ -8336,6 +8451,9 @@ export class LocalBackend { `, { symId }, ); + for (const row of memberRows) { + assertSymbolRowIdentity(row); + } memberRows.sort((a, b) => compareCodeUnits(String(a.id ?? a[0]), String(b.id ?? b[0]))); if (memberRows.length > OBJECT_CALLABLE_MEMBER_CAP) traversalComplete = false; for (const row of memberRows.slice(0, OBJECT_CALLABLE_MEMBER_CAP)) { @@ -8355,6 +8473,7 @@ export class LocalBackend { } } } catch (e) { + rethrowSymbolIdentityError(e); logQueryError('impact:object-callable-expansion', e); traversalComplete = false; } @@ -8411,6 +8530,17 @@ export class LocalBackend { relTypes: relationTypes, ...(safeMinConfidence > 0 ? { minConfidence: safeMinConfidence } : {}), }); + // Validate before filtering/deduplication; a discarded corrupt edge is + // still evidence that this result's counts cannot be trusted. + for (const row of related) { + assertQueryIdentity(row, 'id', 1, [ + ['sourceId', 0], + ['name', 2], + ['type', 3], + ['filePath', 4], + ]); + assertSymbolIdentity(queryRowValue(row, 'relType', 5)); + } const edges: ImpactFrontierEdge[] = related.map((rel) => ({ id: rel.id || rel[1], @@ -8510,6 +8640,7 @@ export class LocalBackend { }); } } catch (e) { + rethrowSymbolIdentityError(e); logQueryError('impact:depth-traversal', e); // Break out of depth loop on query failure but return partial results // collected so far, rather than silently swallowing the error (#321) @@ -8635,6 +8766,7 @@ export class LocalBackend { `, { ids }, ).catch((err) => { + rethrowSymbolIdentityError(err); processQueryFailed = true; enrichmentDegraded = true; logQueryError('impact:process-chunk', err); @@ -8642,6 +8774,14 @@ export class LocalBackend { }); for (const row of rows) { + assertQueryIdentity(row, 'pId', 0, [ + ['name', 1], + ['processType', 2], + ['entryPointId', 3], + ['epName', 7], + ['epType', 8], + ['epFilePath', 9], + ]); const pId = row.pId ?? row[0]; const epId = row.entryPointId ?? row[3] ?? row.pId ?? row[0]; // Track mapping from process -> entryPoint so we can backfill missing minStep @@ -8690,6 +8830,7 @@ export class LocalBackend { ep.earliest_broken_step = Math.min(ep.earliest_broken_step, minStep ?? Infinity); } } catch (e) { + rethrowSymbolIdentityError(e); processQueryFailed = true; enrichmentDegraded = true; logQueryError('impact:process-chunk', e); @@ -8712,12 +8853,14 @@ export class LocalBackend { `, { pIds, ids: allImpactedIds }, ).catch((err) => { + rethrowSymbolIdentityError(err); enrichmentDegraded = true; logQueryError('impact:process-chunk-backfill', err); return []; }); for (const mr of missingRows) { + assertQueryIdentity(mr, 'pid', 0, []); const pid = mr.pid ?? mr[0]; const minStep = mr.minStep ?? mr[1]; const epId = processToEntryPoint.get(String(pid)); @@ -8729,6 +8872,7 @@ export class LocalBackend { } } } catch (e) { + rethrowSymbolIdentityError(e); enrichmentDegraded = true; logQueryError('impact:process-chunk-backfill', e); } @@ -8791,6 +8935,7 @@ export class LocalBackend { `, { ids: idsChunk }, ).catch((err) => { + rethrowSymbolIdentityError(err); moduleQueryFailed = true; enrichmentDegraded = true; logQueryError('impact:module-chunk', err); @@ -8798,12 +8943,14 @@ export class LocalBackend { }); for (const r of rows) { + assertIdentityFields(queryRowValue(r, 'name', 0)); const name = r.name ?? r[0] ?? null; const hits = (r.hits ?? r[1]) || 0; if (!name) continue; moduleHitsMap.set(name, (moduleHitsMap.get(name) || 0) + hits); } } catch (e) { + rethrowSymbolIdentityError(e); moduleQueryFailed = true; enrichmentDegraded = true; logQueryError('impact:module-chunk', e); @@ -8832,16 +8979,19 @@ export class LocalBackend { `, { ids: idsChunk }, ).catch((err) => { + rethrowSymbolIdentityError(err); enrichmentDegraded = true; moduleClassificationFailed = true; logQueryError('impact:direct-module-chunk', err); return []; }); for (const r of rows) { + assertIdentityFields(queryRowValue(r, 'name', 0)); const name = r.name ?? r[0] ?? null; if (name) directModuleSet.add(name); } } catch (e) { + rethrowSymbolIdentityError(e); enrichmentDegraded = true; moduleClassificationFailed = true; logQueryError('impact:direct-module-chunk', e); @@ -8903,11 +9053,14 @@ export class LocalBackend { `, { ids: chunkIds }, ).catch((err) => { + rethrowSymbolIdentityError(err); enrichmentDegraded = true; logQueryError('impact:route-chunk', err); return []; }); for (const row of rows) { + assertSymbolIdentity(queryRowValue(row, 'hid', 0)); + assertIdentityFields(queryRowValue(row, 'url', 1), queryRowValue(row, 'method', 2)); const hid = String(row.hid ?? row[0] ?? ''); const url = row.url ?? row[1]; if (!hid || typeof url !== 'string') continue; @@ -9064,8 +9217,16 @@ export class LocalBackend { p.processType AS pType, MIN(r.step) AS step `, { ids: chunkIds }, - ).catch(() => []); + ).catch((err) => { + rethrowSymbolIdentityError(err); + return []; + }); for (const row of rows) { + assertQueryIdentity(row, 'sid', 0, []); + assertQueryIdentity(row, 'pid', 1, [ + ['pName', 2], + ['pType', 3], + ]); const sid = row.sid ?? row[0]; if (!sid) continue; const procEntry = { @@ -9079,6 +9240,7 @@ export class LocalBackend { else perSymbolProcesses.set(String(sid), [procEntry]); } } catch (e) { + rethrowSymbolIdentityError(e); logQueryError('impact:per-symbol-process-chunk', e); } } diff --git a/gitnexus/src/mcp/local/pdg-impact.ts b/gitnexus/src/mcp/local/pdg-impact.ts index ae4e9836f..4b7321925 100644 --- a/gitnexus/src/mcp/local/pdg-impact.ts +++ b/gitnexus/src/mcp/local/pdg-impact.ts @@ -27,6 +27,11 @@ import { toDisplayLine } from './line-display.js'; import { toOneBasedLine } from '../../core/ingestion/utils/line-base.js'; import { decodeCallSummary } from '../../core/ingestion/taint/call-summary-codec.js'; import { decodeReachingDefReason } from '../../core/ingestion/cfg/reaching-def-reason-codec.js'; +import { + assertSymbolIdentity, + assertIdentityFields, + assertQueryIdentity, +} from './query-result-integrity.js'; /** * Parse the `` segment out of a `BasicBlock` id (1-based function start @@ -90,14 +95,19 @@ const INTERPROC_NODE_BUDGET = 5000; * `classifyPdgBridgeEvidence`); this is the same fact, read at the descent side. */ function parseCalleeIdsCell(raw: unknown): { ids: string[]; truncated: boolean } { + assertIdentityFields(raw); const ids: string[] = []; let truncated = false; + if (!String(raw ?? '').trim()) return { ids, truncated }; // Split on the SHARED CALLEE_ID_SEP (tab) — ids embed file paths / multi-word // C++ type tokens that can contain a space, so a space split would fragment // them. Producer (calleeIdsOfBlock) joins with the same constant. for (const id of String(raw ?? '').split(CALLEE_ID_SEP)) { if (id === CALLEES_TRUNCATED_SENTINEL) truncated = true; - else if (id) ids.push(id); + else { + assertSymbolIdentity(id); + ids.push(id); + } } return { ids, truncated }; } @@ -218,6 +228,7 @@ async function selfReachingDefEdgesByBlock( { ids: blockIds }, ); for (const r of rows as Array>) { + assertQueryIdentity(r, 'id', 0); const id = String(r['id'] ?? ''); if (!id) continue; const decoded = decodeReachingDefReason(r['reason']); @@ -297,6 +308,7 @@ async function pdgStatementsForBlocks( // Narrow the awaited rows ONCE at the boundary to a typed record shape; read // the aliased cells via bracket access with String()/Number() coercion. for (const r of rows as Array>) { + assertQueryIdentity(r, 'id', 0); const id = String(r['id'] ?? ''); const line = Number(r['line'] ?? 0); if (!id || !Number.isFinite(line) || line <= 0) continue; @@ -501,6 +513,10 @@ async function projectBlocksToSymbols(deps: { // non-aliased row shape) — no per-field `as any`, matching the typed-row // pattern used elsewhere in this file (e.g. lines ~264, ~1309, ~1386). for (const r of rows as Array>) { + assertQueryIdentity(r, 'id', 0, [ + ['name', 1], + ['label', 2], + ]); resolved.push({ id: String(r['id'] ?? r['0'] ?? ''), name: String(r['name'] ?? r['1'] ?? ''), @@ -1718,6 +1734,7 @@ async function bfsReachableBlocks(input: { // Narrow the awaited rows ONCE at the boundary (executeParameterized returns // any[]) to a typed record shape, then read the aliased `id` via bracket // access — no `as any` sprayed per field. + for (const row of rawRows) assertQueryIdentity(row, 'id', 0); const rows = rawRows.slice(0, stepLimit) as Array>; depthReached = depth + 1; if (rawRows.length > stepLimit) truncatedByLimit = true; @@ -1796,6 +1813,10 @@ async function calleeIdsByBlock( // Narrow the awaited rows ONCE at the boundary to a typed record shape; read // the aliased cells via bracket access — no per-field `as any`. for (const r of rows as Array>) { + assertQueryIdentity(r, 'id', 0, [ + ['calleeIds', 1], + ['callees', 2], + ]); const blockId = String(r['id'] ?? ''); if (!blockId) continue; // ONE pass over the cell classifies BOTH facts — a second full split just to @@ -1877,6 +1898,7 @@ async function calleesWithReturnFlow( { ids: calleeIds }, ); for (const r of rows as Array>) { + assertQueryIdentity(r, 'id', 0); const id = String(r['id'] ?? ''); if (!id) continue; const decoded = decodeCallSummary(r['reason']); @@ -1931,6 +1953,7 @@ async function resolveCalleeSpans( // the aliased columns via bracket access with Number()/String() coercion — // no per-field `as any` (the same boundary-narrowing the typed helpers use). for (const r of rows as Array>) { + assertQueryIdentity(r, 'id', 0, [['filePath', 1]]); const id = String(r['id'] ?? ''); const filePath = String(r['filePath'] ?? ''); const startLine = Number(r['startLine']); @@ -2168,6 +2191,7 @@ async function interproceduralDescent(input: { seedBlockQuery(anchorClause, probeLimit), queryParams, ); + for (const row of rawSeedRows) assertQueryIdentity(row, 'id', 0); const exceeded = rawSeedRows.length > stepLimit; const seeds = rawSeedRows .slice(0, stepLimit) @@ -2353,6 +2377,7 @@ export async function runImpactPDG(deps: RunPdgImpactDeps): Promise>; let seedBlocks: string[] = seedRows .map((r) => String(r['id'] ?? '')) diff --git a/gitnexus/src/mcp/local/query-result-integrity.ts b/gitnexus/src/mcp/local/query-result-integrity.ts new file mode 100644 index 000000000..da92cca55 --- /dev/null +++ b/gitnexus/src/mcp/local/query-result-integrity.ts @@ -0,0 +1,57 @@ +/** Shared integrity boundary for identities returned by impact/context queries. */ +export const SYMBOL_IDENTITY_RECOVERY_SUGGESTION = + 'Run gitnexus analyze --force from the affected repository root to rebuild the index.'; + +export class SymbolIdentityError extends Error { + constructor() { + super('The index returned an invalid symbol identity. ' + SYMBOL_IDENTITY_RECOVERY_SUGGESTION); + this.name = 'SymbolIdentityError'; + } +} + +/** Validate database identities before using them as graph traversal anchors. */ +export function assertSymbolIdentity(id: unknown, expectedUid?: string): asserts id is string { + if ( + typeof id !== 'string' || + !id.trim() || + id.includes('\0') || + (expectedUid !== undefined && id !== expectedUid) + ) { + throw new SymbolIdentityError(); + } +} + +/** Read either native row shape without turning an absent row into a TypeError. */ +export function queryRowValue(row: unknown, key: string, index: number): unknown { + if (typeof row !== 'object' || row === null) return undefined; + const value = row as Record; + return value[key] ?? value[index]; +} + +/** Optional labels/paths may be empty or NULL; NUL is never a usable identity. */ +export function assertIdentityFields(...values: unknown[]): void { + for (const value of values) { + if ( + value !== null && + value !== undefined && + (typeof value !== 'string' || value.includes('\0')) + ) { + throw new SymbolIdentityError(); + } + } +} + +export function assertQueryIdentity( + row: unknown, + idKey: string, + idIndex: number, + fields: ReadonlyArray = [], +): void { + assertSymbolIdentity(queryRowValue(row, idKey, idIndex)); + for (const [key, index] of fields) assertIdentityFields(queryRowValue(row, key, index)); +} + +/** Ordinary query failures may degrade; corrupt identities must reach the outer error envelope. */ +export function rethrowSymbolIdentityError(error: unknown): void { + if (error instanceof SymbolIdentityError) throw error; +} diff --git a/gitnexus/test/integration/impact-context-integrity.test.ts b/gitnexus/test/integration/impact-context-integrity.test.ts new file mode 100644 index 000000000..0e42de484 --- /dev/null +++ b/gitnexus/test/integration/impact-context-integrity.test.ts @@ -0,0 +1,758 @@ +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import lbug from '@ladybugdb/core'; +import * as adapter from '../../src/core/lbug/lbug-adapter.js'; +import { executeParameterized } from '../../src/core/lbug/pool-adapter.js'; +import { closeQueryResults } from '../../src/core/lbug/query-result-utils.js'; +import { LocalBackend } from '../../src/mcp/local/local-backend.js'; +import { retryRename } from '../../src/storage/fs-atomic.js'; +import { getStoragePaths, registerRepo, saveMeta } from '../../src/storage/repo-manager.js'; +import { createTempDir } from '../helpers/test-db.js'; + +const REPO = 'query-integrity'; +const nodes = { + alpha: { id: 'Function:src/alpha.ts:runSweep', name: 'runSweep', filePath: 'src/alpha.ts' }, + alphaCaller: { + id: 'Function:src/α/caller.ts:appelÉ', + name: 'appelÉ', + filePath: 'src/α/caller.ts', + }, + alphaOtherCaller: { + id: 'Function:src/other.ts:runOther', + name: 'runOther', + filePath: 'src/other.ts', + }, + alphaRoot: { id: 'Function:src/root.ts:startSweep', name: 'startSweep', filePath: 'src/root.ts' }, + alphaReader: { + id: 'Function:src/reader.ts:readSweep', + name: 'readSweep', + filePath: 'src/reader.ts', + }, + beta: { + id: 'Function:src/beta.ts:extractLeadingNumber', + name: 'extractLeadingNumber', + filePath: 'src/beta.ts', + }, + betaCaller: { + id: 'Function:src/number.ts:parseNumber', + name: 'parseNumber', + filePath: 'src/number.ts', + }, + betaReader: { + id: 'Function:src/number-view.ts:readNumber', + name: 'readNumber', + filePath: 'src/number-view.ts', + }, +} as const; + +const processes = { + alpha: { + id: 'process:alpha', + label: 'Sweep flow', + entry: nodes.alphaRoot, + terminal: nodes.alpha, + stepCount: 3, + }, + alphaOther: { + id: 'process:alpha-other', + label: 'Other sweep flow', + entry: nodes.alphaOtherCaller, + terminal: nodes.alpha, + stepCount: 2, + }, + beta: { + id: 'process:beta', + label: 'Number flow', + entry: nodes.betaCaller, + terminal: nodes.beta, + stepCount: 2, + }, +} as const; + +type NodeIdentity = { id: string; name: string; filePath: string }; +type Membership = { id: string; label: string; processType: string; step: number }; +type ImpactRow = NodeIdentity & { + relationType: string; + confidence: number; + processes: Membership[]; +}; +type ContextRef = { uid: string; name: string; filePath: string }; +type Target = 'alpha' | 'beta'; + +const membership = ( + process: (typeof processes)[keyof typeof processes], + step: number, +): Membership => ({ + id: process.id, + label: process.label, + processType: 'intra_community', + step, +}); + +const affectedProcess = (process: (typeof processes)[keyof typeof processes], hits: number) => ({ + name: process.entry.name, + type: 'Function', + filePath: process.entry.filePath, + affected_process_count: 1, + total_hits: hits, + earliest_broken_step: 0, +}); + +const oracle = { + alpha: { + count: 3, + direct: 2, + byDepth: { + 1: [ + { + ...nodes.alphaOtherCaller, + relationType: 'CALLS', + confidence: 1, + processes: [membership(processes.alphaOther, 0)], + }, + { + ...nodes.alphaCaller, + relationType: 'CALLS', + confidence: 1, + processes: [membership(processes.alpha, 1)], + }, + ], + 2: [ + { + ...nodes.alphaRoot, + relationType: 'CALLS', + confidence: 1, + processes: [membership(processes.alpha, 0)], + }, + ], + }, + callers: [nodes.alphaOtherCaller, nodes.alphaCaller], + accesses: [nodes.alphaReader], + processes: [ + { id: processes.alpha.id, name: processes.alpha.label, step_index: 2, step_count: 3 }, + { + id: processes.alphaOther.id, + name: processes.alphaOther.label, + step_index: 1, + step_count: 2, + }, + ], + affectedProcesses: [ + affectedProcess(processes.alpha, 2), + affectedProcess(processes.alphaOther, 1), + ], + }, + beta: { + count: 1, + direct: 1, + byDepth: { + 1: [ + { + ...nodes.betaCaller, + relationType: 'CALLS', + confidence: 1, + processes: [membership(processes.beta, 0)], + }, + ], + }, + callers: [nodes.betaCaller], + accesses: [nodes.betaReader], + processes: [ + { id: processes.beta.id, name: processes.beta.label, step_index: 1, step_count: 2 }, + ], + affectedProcesses: [affectedProcess(processes.beta, 1)], + }, +} as const; + +// Compare the values returned by the native engine with a hand-written graph +// oracle, rather than accepting a repeated (and potentially wrong) first result. +function expectImpact( + result: Awaited>, + target: Target, + summaryOnly: boolean, +): void { + const expected = oracle[target]; + expect(result).not.toHaveProperty('error'); + expect(result).not.toHaveProperty('partial'); + expect(result.target).toMatchObject(nodes[target]); + expect(result.direction).toBe('upstream'); + expect(result.impactedCount).toBe(expected.count); + expect(result.risk).toBe('LOW'); + expect(result.epistemic).toBe('exact'); + expect(result.summary).toEqual({ + direct: expected.direct, + processes_affected: expected.affectedProcesses.length, + modules_affected: 0, + }); + expect(result.byDepthCounts).toEqual(target === 'alpha' ? { 1: 2, 2: 1 } : { 1: 1 }); + expect(result.affected_processes).toEqual(expected.affectedProcesses); + expect(result.affected_modules).toEqual([]); + expect(result.affected_routes).toEqual([]); + if (summaryOnly) { + expect(result).not.toHaveProperty('byDepth'); + } else { + const byDepth = Object.fromEntries( + Object.entries(result.byDepth).map(([depth, rows]) => [ + depth, + (rows as ImpactRow[]).map( + ({ id, name, filePath, relationType, confidence, processes: memberships }) => ({ + id, + name, + filePath, + relationType, + confidence, + processes: memberships, + }), + ), + ]), + ); + expect(byDepth).toEqual(expected.byDepth); + } +} + +function expectContext( + result: Awaited>, + target: Target, +): void { + const expected = oracle[target]; + expect(result).not.toHaveProperty('error'); + expect(result.status).toBe('found'); + expect(result.symbol).toMatchObject({ + uid: nodes[target].id, + name: nodes[target].name, + filePath: nodes[target].filePath, + }); + expect(result.epistemic).toBe('exact'); + const incoming = Object.fromEntries( + Object.entries(result.incoming).map(([type, refs]) => [ + type, + (refs as ContextRef[]).map(({ uid, name, filePath }) => ({ id: uid, name, filePath })), + ]), + ); + expect(incoming).toEqual({ calls: expected.callers, accesses: expected.accesses }); + expect(result.outgoing).toEqual({}); + expect(result.processes).toEqual(expected.processes); +} + +function edge(source: NodeIdentity, target: NodeIdentity, type: 'CALLS' | 'ACCESSES'): string { + return `MATCH (a:Function {id: '${source.id}'}), (b:Function {id: '${target.id}'}) CREATE (a)-[:CodeRelation {type: '${type}', confidence: 1.0, reason: 'direct', step: 0}]->(b)`; +} + +function processStep( + node: NodeIdentity, + process: (typeof processes)[keyof typeof processes], + step: number, +): string { + return `MATCH (n:Function {id: '${node.id}'}), (p:Process {id: '${process.id}'}) CREATE (n)-[:CodeRelation {type: 'STEP_IN_PROCESS', confidence: 1.0, reason: 'trace-detection', step: ${step}}]->(p)`; +} + +describe('native impact/context result integrity (#3354)', () => { + let temp: Awaited>; + let backend: LocalBackend; + let lbugPath: string; + + beforeAll(async () => { + temp = await createTempDir(); + vi.stubEnv('GITNEXUS_HOME', path.join(temp.dbPath, 'home')); + vi.stubEnv('GITNEXUS_STORAGE_PATH', path.join(temp.dbPath, 'index')); + vi.stubEnv('GITNEXUS_SHARED_STORE', 'off'); + const paths = getStoragePaths(temp.dbPath); + lbugPath = paths.lbugPath; + + // Close the writer before LocalBackend opens its ordinary read pool. No + // mocked registry or injected writable Database bypasses the read path. + await adapter.initLbug(lbugPath); + try { + const seed = [ + ...Object.values(nodes).map( + (node) => + `CREATE (:Function {id: '${node.id}', name: '${node.name}', filePath: '${node.filePath}', startLine: 1, endLine: 3})`, + ), + ...Object.values(processes).map( + (process) => + `CREATE (:Process {id: '${process.id}', label: '${process.label}', heuristicLabel: '${process.label}', processType: 'intra_community', stepCount: ${process.stepCount}, communities: [], entryPointId: '${process.entry.id}', terminalId: '${process.terminal.id}'})`, + ), + edge(nodes.alphaCaller, nodes.alpha, 'CALLS'), + edge(nodes.alphaOtherCaller, nodes.alpha, 'CALLS'), + edge(nodes.alphaRoot, nodes.alphaCaller, 'CALLS'), + edge(nodes.alphaReader, nodes.alpha, 'ACCESSES'), + edge(nodes.betaCaller, nodes.beta, 'CALLS'), + edge(nodes.betaReader, nodes.beta, 'ACCESSES'), + processStep(nodes.alphaRoot, processes.alpha, 0), + processStep(nodes.alphaCaller, processes.alpha, 1), + processStep(nodes.alpha, processes.alpha, 2), + processStep(nodes.alphaOtherCaller, processes.alphaOther, 0), + processStep(nodes.alpha, processes.alphaOther, 1), + processStep(nodes.betaCaller, processes.beta, 0), + processStep(nodes.beta, processes.beta, 1), + ]; + for (const query of seed) await adapter.executeQuery(query); + await adapter.flushWAL(); + } finally { + await adapter.closeLbug(); + } + const meta = { + repoPath: temp.dbPath, + storagePath: paths.storagePath, + lastCommit: 'integrity-fixture', + indexedAt: new Date().toISOString(), + scopeExtractionReceipt: 1 as const, + stats: { files: 8, nodes: 11, processes: 3, communities: 0 }, + }; + await saveMeta(paths.storagePath, meta); + await registerRepo(temp.dbPath, meta, { name: REPO }); + backend = new LocalBackend(); + expect(await backend.init()).toBe(true); + }); + + afterAll(async () => { + try { + await backend?.dispose(); + } finally { + await adapter.closeLbug(); + vi.unstubAllEnvs(); + await temp?.cleanup(); + } + }); + + const impact = (target: Target, summaryOnly = false) => + backend.callTool('impact', { + repo: REPO, + target: nodes[target].name, + direction: 'upstream', + summaryOnly, + }); + const context = (target: Target) => + backend.callTool('context', { repo: REPO, uid: nodes[target].id }); + + it('returns exact values across identical sequential requests', async () => { + for (let repeat = 0; repeat < 6; repeat++) { + expectImpact(await impact('alpha', true), 'alpha', true); + expectContext(await context('alpha'), 'alpha'); + expectImpact(await impact('alpha'), 'alpha', false); + } + }); + + it('keeps unrelated targets isolated across mixed requests', async () => { + for (const target of ['alpha', 'beta', 'beta', 'alpha'] as const) { + expectImpact(await impact(target, true), target, true); + expectContext(await context(target), target); + expectImpact(await impact(target), target, false); + } + }); + + it('keeps mixed concurrent prepared reads symbol-specific', async () => { + for (let repeat = 0; repeat < 3; repeat++) { + const results = await Promise.all([ + impact('alpha', true), + context('beta'), + impact('beta'), + impact('beta', true), + context('alpha'), + impact('alpha'), + ]); + expectImpact(results[0], 'alpha', true); + expectContext(results[1], 'beta'); + expectImpact(results[2], 'beta', false); + expectImpact(results[3], 'beta', true); + expectContext(results[4], 'alpha'); + expectImpact(results[5], 'alpha', false); + } + }); + + it('returns exact relation rows directly from the pooled prepared adapter', async () => { + // Warm the pool through the same backend, then check the native row + // boundary independently of the tool's normalization and aggregation. + expectContext(await context('alpha'), 'alpha'); + const read = (target: Target) => + executeParameterized( + lbugPath, + ` + MATCH (caller:Function)-[r:CodeRelation]->(target:Function {id: $id}) + WHERE r.type IN ['CALLS', 'ACCESSES'] + RETURN caller.id AS id, caller.name AS name, caller.filePath AS filePath, r.type AS relationType + ORDER BY id + `, + { id: nodes[target].id }, + ); + const expectedRows = (target: Target) => + [ + ...oracle[target].callers.map((caller) => ({ ...caller, relationType: 'CALLS' })), + ...oracle[target].accesses.map((reader) => ({ ...reader, relationType: 'ACCESSES' })), + ].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)); + for (let repeat = 0; repeat < 4; repeat++) { + expect(await read('alpha')).toEqual(expectedRows('alpha')); + const [beta, alpha] = await Promise.all([read('beta'), read('alpha')]); + expect(beta).toEqual(expectedRows('beta')); + expect(alpha).toEqual(expectedRows('alpha')); + } + }); +}); + +describe('native string projections after checkpointed deletion (#3354)', () => { + it('keeps long symbol identities associated with their source rows across segments', async () => { + const temp = await createTempDir(); + const db = new lbug.Database(path.join(temp.dbPath, 'scan.lbug'), 128 * 1024 * 1024); + const conn = new lbug.Connection(db, 4); + const source = Array.from({ length: 10_000 }, (_, startLine) => ({ + id: `Function:src/generated/rené-${String(startLine).padStart(5, '0')}.ts:fn${startLine}`, + name: `generated_function_${startLine}_é`, + filePath: `src/generated/rené-${String(startLine).padStart(5, '0')}.ts`, + startLine, + })); + const projection = + 'RETURN n.id AS id, n.name AS name, n.filePath AS filePath, n.startLine AS startLine'; + const read = async (query: string) => { + const result = await conn.query(query); + try { + const cursor = Array.isArray(result) ? result[0] : result; + return await cursor.getAll(); + } finally { + await closeQueryResults(result); + } + }; + + try { + await read( + 'CREATE NODE TABLE Function(id STRING, name STRING, filePath STRING, startLine INT64, PRIMARY KEY(id))', + ); + // Separate checkpoints create segment boundaries inside scan vectors. + // LadybugDB 0.18.3's filtered STRING scan could retain another row's + // printable identities here (LadybugDB/ladybug#678, fixed by #737). + for (let batch = 0; batch < 4; batch++) { + const csvPath = path.join(temp.dbPath, `rows-${batch}.csv`); + const csv = source + .slice(batch * 2500, (batch + 1) * 2500) + .map((row) => + Object.values(row) + .map((value) => JSON.stringify(value)) + .join(','), + ) + .join('\n'); + await fs.writeFile(csvPath, `${csv}\n`); + await read( + `COPY Function FROM ${JSON.stringify(csvPath.replaceAll('\\', '/'))} (HEADER=false)`, + ); + await read('CHECKPOINT'); + } + expect(await read(`MATCH (n:Function) ${projection} ORDER BY n.startLine`)).toEqual(source); + + await read( + 'MATCH (n:Function) WHERE n.startLine >= 3000 AND n.startLine < 3400 DETACH DELETE n', + ); + await read('CHECKPOINT'); + const surviving = source.filter((row) => row.startLine < 3000 || row.startLine >= 3400); + for (let repeat = 0; repeat < 3; repeat++) { + for (const order of ['', ' ORDER BY n.startLine']) { + const rows = await read(`MATCH (n:Function) ${projection}${order}`); + expect(new Set(rows.map((row) => row.id)).size).toBe(surviving.length); + expect(rows.sort((a, b) => a.startLine - b.startLine)).toEqual(surviving); + } + } + // Point lookups independently verify values in the affected segments; + // a repeatably wrong scan must never become the test's reference answer. + for (const startLine of [1600, 7486]) { + expect( + await read(`MATCH (n:Function {id: '${source[startLine].id}'}) ${projection}`), + ).toEqual([source[startLine]]); + } + expect(await read(`MATCH (n:Function {id: '${source[3000].id}'}) ${projection}`)).toEqual([]); + } finally { + try { + await conn.close(); + } finally { + try { + await db.close(); + } finally { + await temp.cleanup(); + } + } + } + }); +}); + +// Windows graph replacement is opt-in in production. The repeated-read +// characterization above remains enabled there; only this POSIX swap is skipped. +describe.skipIf(process.platform === 'win32')('warm backend index replacement (#3354)', () => { + it('reads changed callers, processes and a new symbol through the real freshness window', async () => { + const temp = await createTempDir(); + let backend: LocalBackend | undefined; + vi.stubEnv('GITNEXUS_HOME', path.join(temp.dbPath, 'home')); + vi.stubEnv('GITNEXUS_STORAGE_PATH', path.join(temp.dbPath, 'index')); + vi.stubEnv('GITNEXUS_SHARED_STORE', 'off'); + const paths = getStoragePaths(temp.dbPath); + const stagedPath = `${paths.lbugPath}.replacement`; + const replacement = { + entry: { + id: 'Function:src/replacement-entry.ts:startReplacement', + name: 'startReplacement', + filePath: 'src/replacement-entry.ts', + }, + caller: { + id: 'Function:src/replacement-caller.ts:callReplacement', + name: 'callReplacement', + filePath: 'src/replacement-caller.ts', + }, + reader: { + id: 'Function:src/replacement-reader.ts:readReplacement', + name: 'readReplacement', + filePath: 'src/replacement-reader.ts', + }, + }; + const nextProcess = { id: 'process:replacement', label: 'Replacement flow' }; + const oldProcess = processes.alphaOther; + + const seed = async (dbPath: string, next: boolean) => { + await adapter.initLbug(dbPath); + try { + const caller = next ? replacement.caller : nodes.alphaOtherCaller; + const reader = next ? replacement.reader : nodes.alphaReader; + const process = next ? nextProcess : oldProcess; + const entry = next ? replacement.entry : caller; + for (const node of [nodes.alpha, caller, reader, ...(next ? [entry] : [])]) { + await adapter.executeQuery( + `CREATE (:Function {id: '${node.id}', name: '${node.name}', filePath: '${node.filePath}', startLine: 1, endLine: 3})`, + ); + } + await adapter.executeQuery( + `CREATE (:Process {id: '${process.id}', label: '${process.label}', heuristicLabel: '${process.label}', processType: 'intra_community', stepCount: ${next ? 3 : 2}, communities: [], entryPointId: '${entry.id}', terminalId: '${nodes.alpha.id}'})`, + ); + await adapter.executeQuery(edge(caller, nodes.alpha, 'CALLS')); + await adapter.executeQuery(edge(reader, nodes.alpha, 'ACCESSES')); + if (next) await adapter.executeQuery(edge(entry, caller, 'CALLS')); + const steps = next ? [entry, caller, nodes.alpha] : [caller, nodes.alpha]; + for (const [step, node] of steps.entries()) { + await adapter.executeQuery( + `MATCH (n:Function {id: '${node.id}'}), (p:Process {id: '${process.id}'}) CREATE (n)-[:CodeRelation {type: 'STEP_IN_PROCESS', confidence: 1.0, reason: 'trace-detection', step: ${step}}]->(p)`, + ); + } + await adapter.flushWAL(); + } finally { + await adapter.closeLbug(); + } + }; + const processMembership = (next: boolean, step: number) => ({ + ...(next ? nextProcess : { id: oldProcess.id, label: oldProcess.label }), + processType: 'intra_community', + step, + }); + const expectedCaller = (node: NodeIdentity, next: boolean, step: number) => ({ + ...node, + relationType: 'CALLS', + confidence: 1, + processes: [processMembership(next, step)], + }); + const expectGeneration = ( + impact: Awaited>, + context: Awaited>, + next: boolean, + ) => { + const caller = next ? replacement.caller : nodes.alphaOtherCaller; + const reader = next ? replacement.reader : nodes.alphaReader; + const entry = next ? replacement.entry : caller; + const process = next ? nextProcess : oldProcess; + expect(impact).not.toHaveProperty('error'); + expect(impact).not.toHaveProperty('partial'); + expect(impact.target).toMatchObject(nodes.alpha); + expect(impact.risk).toBe('LOW'); + expect(impact.epistemic).toBe('exact'); + expect(impact.impactedCount).toBe(next ? 2 : 1); + expect(impact.summary).toEqual({ direct: 1, processes_affected: 1, modules_affected: 0 }); + expect(impact.byDepthCounts).toEqual(next ? { 1: 1, 2: 1 } : { 1: 1 }); + const byDepth = Object.fromEntries( + Object.entries(impact.byDepth).map(([depth, rows]) => [ + depth, + (rows as ImpactRow[]).map( + ({ id, name, filePath, relationType, confidence, processes: memberships }) => ({ + id, + name, + filePath, + relationType, + confidence, + processes: memberships, + }), + ), + ]), + ); + expect(byDepth).toEqual({ + 1: [expectedCaller(caller, next, next ? 1 : 0)], + ...(next ? { 2: [expectedCaller(entry, true, 0)] } : {}), + }); + expect(impact.affected_processes).toEqual([ + { + name: entry.name, + type: 'Function', + filePath: entry.filePath, + affected_process_count: 1, + total_hits: next ? 2 : 1, + earliest_broken_step: 0, + }, + ]); + expect(impact.affected_modules).toEqual([]); + expect(impact.affected_routes).toEqual([]); + expect(context).not.toHaveProperty('error'); + expect(context.status).toBe('found'); + expect(context.epistemic).toBe('exact'); + expect(context.symbol).toMatchObject({ + uid: nodes.alpha.id, + name: nodes.alpha.name, + filePath: nodes.alpha.filePath, + }); + expect( + Object.fromEntries( + Object.entries(context.incoming).map(([type, refs]) => [ + type, + (refs as ContextRef[]).map(({ uid, name, filePath }) => ({ id: uid, name, filePath })), + ]), + ), + ).toEqual({ calls: [caller], accesses: [reader] }); + expect(context.outgoing).toEqual({}); + expect(context.processes).toEqual([ + { + id: process.id, + name: process.label, + step_index: next ? 2 : 1, + step_count: next ? 3 : 2, + }, + ]); + }; + + try { + await seed(paths.lbugPath, false); + const meta = { + repoPath: temp.dbPath, + storagePath: paths.storagePath, + lastCommit: 'graph-a', + indexedAt: new Date().toISOString(), + scopeExtractionReceipt: 1 as const, + stats: { files: 3, nodes: 4, processes: 1, communities: 0 }, + }; + await saveMeta(paths.storagePath, meta); + await registerRepo(temp.dbPath, meta, { name: REPO }); + const heldBackend = new LocalBackend(); + backend = heldBackend; + expect(await heldBackend.init()).toBe(true); + const impact = () => + heldBackend.callTool('impact', { + repo: REPO, + target: nodes.alpha.name, + direction: 'upstream', + }); + const context = () => heldBackend.callTool('context', { repo: REPO, uid: nodes.alpha.id }); + expectGeneration(await impact(), await context(), false); + expect( + await heldBackend.callTool('context', { repo: REPO, uid: replacement.entry.id }), + ).toHaveProperty('error'); + + // Keep this backend and its read pool alive. Publish only after the + // separate staged writer has closed, exactly as run-analyze does. + await seed(stagedPath, true); + for (const suffix of ['.wal', '.shadow', '.wal.checkpoint']) { + await expect(fs.stat(`${stagedPath}${suffix}`)).rejects.toMatchObject({ code: 'ENOENT' }); + } + await retryRename(stagedPath, paths.lbugPath); + const nextMeta = { + ...meta, + lastCommit: 'graph-b', + indexedAt: new Date(Date.now() + 1).toISOString(), + stats: { files: 4, nodes: 5, processes: 1, communities: 0 }, + }; + await saveMeta(paths.storagePath, nextMeta); + await registerRepo(temp.dbPath, nextMeta, { name: REPO }); + + // An independent native read-only Database opens the published path. + // It does not share LocalBackend's pool or trigger its reinitialization. + const freshDb = new lbug.Database(paths.lbugPath, 128 * 1024 * 1024, true, true); + const freshConn = new lbug.Connection(freshDb); + try { + const read = async (query: string) => { + const result = await freshConn.query(query); + try { + const cursor = Array.isArray(result) ? result[0] : result; + return await cursor.getAll(); + } finally { + await closeQueryResults(result); + } + }; + expect( + await read(` + MATCH (n:Function) + RETURN n.id AS id, n.name AS name, n.filePath AS filePath + ORDER BY id + `), + ).toEqual( + [nodes.alpha, ...Object.values(replacement)].sort((a, b) => + a.id < b.id ? -1 : a.id > b.id ? 1 : 0, + ), + ); + expect( + await read(` + MATCH (n:Function)-[r:CodeRelation]->(target:Function {id: '${nodes.alpha.id}'}) + WHERE r.type IN ['CALLS', 'ACCESSES'] + RETURN n.id AS id, n.name AS name, n.filePath AS filePath, r.type AS relationType + ORDER BY id + `), + ).toEqual([ + { ...replacement.caller, relationType: 'CALLS' }, + { ...replacement.reader, relationType: 'ACCESSES' }, + ]); + expect( + await read(` + MATCH (n:Function)-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p:Process) + RETURN n.id AS id, p.id AS processId, p.heuristicLabel AS label, + r.step AS step, p.stepCount AS stepCount, p.entryPointId AS entryPointId + ORDER BY step + `), + ).toEqual( + [replacement.entry, replacement.caller, nodes.alpha].map((node, step) => ({ + id: node.id, + processId: nextProcess.id, + label: nextProcess.label, + step, + stepCount: 3, + entryPointId: replacement.entry.id, + })), + ); + } finally { + await freshConn.close(); + await freshDb.close(); + } + + // Poll the SAME backend through its unchanged five-second throttle. + // No private watermark override, poolInit, reset or restart is used. + const deadline = Date.now() + 15_000; + let refreshed = await context(); + while (refreshed.processes?.[0]?.id !== nextProcess.id && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 300)); + refreshed = await context(); + } + expectGeneration(await impact(), refreshed, true); + for (let repeat = 0; repeat < 3; repeat++) { + expectGeneration(await impact(), await context(), true); + const introduced = await heldBackend.callTool('context', { + repo: REPO, + name: replacement.entry.name, + }); + expect(introduced).not.toHaveProperty('error'); + expect(introduced.status).toBe('found'); + expect(introduced.symbol).toMatchObject({ + uid: replacement.entry.id, + name: replacement.entry.name, + filePath: replacement.entry.filePath, + }); + expect(introduced.processes).toEqual([ + { id: nextProcess.id, name: nextProcess.label, step_index: 0, step_count: 3 }, + ]); + } + } finally { + try { + await backend?.dispose(); + } finally { + await adapter.closeLbug(); + vi.unstubAllEnvs(); + await temp.cleanup(); + } + } + }); +}); diff --git a/gitnexus/test/unit/calltool-dispatch-id-bridge.test.ts b/gitnexus/test/unit/calltool-dispatch-id-bridge.test.ts index 0390caab6..be6d32215 100644 --- a/gitnexus/test/unit/calltool-dispatch-id-bridge.test.ts +++ b/gitnexus/test/unit/calltool-dispatch-id-bridge.test.ts @@ -159,9 +159,8 @@ describe('LocalBackend PDG impact — resolved-callee-id bridge (U6)', () => { if (query.includes('r.type IN $relTypes') && !query.includes('STEP_IN_PROCESS')) { return [frontierRow('func:callee-A', 'callee')]; } - if (query.includes('COUNT(DISTINCT s.id)') || query.includes('RETURN s.id AS sid')) return []; - // Target resolution (WHERE n.name = $symName) and any other read. - return [TARGET_ROW]; + if (query.includes('WHERE n.name = $symName')) return [TARGET_ROW]; + return []; }); const result = await backend.callTool('impact', { @@ -195,8 +194,8 @@ describe('LocalBackend PDG impact — resolved-callee-id bridge (U6)', () => { if (query.includes('r.type IN $relTypes') && !query.includes('STEP_IN_PROCESS')) { return [frontierRow('func:callee-A', 'callee')]; } - if (query.includes('COUNT(DISTINCT s.id)') || query.includes('RETURN s.id AS sid')) return []; - return [TARGET_ROW]; + if (query.includes('WHERE n.name = $symName')) return [TARGET_ROW]; + return []; }); const result = await backend.callTool('impact', { @@ -230,8 +229,8 @@ describe('LocalBackend PDG impact — resolved-callee-id bridge (U6)', () => { if (query.includes('r.type IN $relTypes') && !query.includes('STEP_IN_PROCESS')) { return [frontierRow('func:callee-A', 'callee'), frontierRow('func:callee-B', 'callee')]; } - if (query.includes('COUNT(DISTINCT s.id)') || query.includes('RETURN s.id AS sid')) return []; - return [TARGET_ROW]; + if (query.includes('WHERE n.name = $symName')) return [TARGET_ROW]; + return []; }); const result = await backend.callTool('impact', { @@ -266,8 +265,8 @@ describe('LocalBackend PDG impact — resolved-callee-id bridge (U6)', () => { if (query.includes('r.type IN $relTypes') && !query.includes('STEP_IN_PROCESS')) { return [frontierRow('func:callee-A', 'callee'), frontierRow('*', 'callee')]; } - if (query.includes('COUNT(DISTINCT s.id)') || query.includes('RETURN s.id AS sid')) return []; - return [TARGET_ROW]; + if (query.includes('WHERE n.name = $symName')) return [TARGET_ROW]; + return []; }); const result = await backend.callTool('impact', { diff --git a/gitnexus/test/unit/calltool-dispatch.test.ts b/gitnexus/test/unit/calltool-dispatch.test.ts index aeefc94a4..7eaf2e1ff 100644 --- a/gitnexus/test/unit/calltool-dispatch.test.ts +++ b/gitnexus/test/unit/calltool-dispatch.test.ts @@ -183,6 +183,16 @@ function setupNoRepos() { (listRegisteredRepos as any).mockResolvedValue([]); } +/** Seed resolver rows without inventing relationship/process rows for other projections. */ +function mockSymbolRows(rows: Record[]) { + (executeParameterized as any).mockImplementation( + async (_repo: string, query: string, params: Record) => { + if (query.includes('COUNT(*) AS total')) return [{ total: rows.length }]; + return params?.symName || params?.uid ? rows : []; + }, + ); +} + const duplicateFixtureDirs: string[] = []; function makeDuplicateNameFixture() { @@ -1321,7 +1331,7 @@ describe('LocalBackend.callTool', () => { }); it('dispatches context tool', async () => { - (executeParameterized as any).mockResolvedValue([ + mockSymbolRows([ { id: 'func:main', name: 'main', @@ -1663,27 +1673,22 @@ describe('LocalBackend.callTool', () => { }); it('exact File path wins over suffixed matches during qualified resolution (#3084 review P2)', async () => { - (executeParameterized as any).mockImplementation(async (_repo: string, query: string) => { - if (query.startsWith('MATCH (n)')) { - return [ - { - id: 'File:src/lib/a.ts', - name: 'a.ts', - filePath: 'src/lib/a.ts', - kind: 'File', - total_hits: 1, - }, - { - id: 'File:lib/a.ts', - name: 'a.ts', - filePath: 'lib/a.ts', - kind: 'File', - total_hits: 1, - }, - ]; - } - return [{ total: 2 }]; - }); + mockSymbolRows([ + { + id: 'File:src/lib/a.ts', + name: 'a.ts', + filePath: 'src/lib/a.ts', + kind: 'File', + total_hits: 1, + }, + { + id: 'File:lib/a.ts', + name: 'a.ts', + filePath: 'lib/a.ts', + kind: 'File', + total_hits: 1, + }, + ]); const result = await backend.callTool('context', { name: 'lib/a.ts' }); expect(result).toMatchObject({ @@ -2005,7 +2010,7 @@ describe('LocalBackend.callTool', () => { }); it('context tool ranks file_path match higher than non-match (#470)', async () => { - (executeParameterized as any).mockResolvedValue([ + mockSymbolRows([ { id: 'func:handleConnect:1', name: 'handleConnect', @@ -2044,7 +2049,7 @@ describe('LocalBackend.callTool', () => { // review): both candidates satisfy the file_path hint (so DB // pre-filter would return both in production), and promotion is // determined purely by the combined file_path + kind score. - (executeParameterized as any).mockResolvedValue([ + mockSymbolRows([ { id: 'fn:App:1', name: 'render', @@ -2135,7 +2140,7 @@ describe('LocalBackend.callTool', () => { it('impact tool returns ambiguous shape with ranked candidates when target has multiple matches (#470)', async () => { // resolveSymbolCandidates issues a single name query; mock it to return // two Function rows in different files with no hints. - (executeParameterized as any).mockResolvedValue([ + mockSymbolRows([ { id: 'func:login:1', name: 'login', @@ -2222,7 +2227,7 @@ describe('LocalBackend.callTool', () => { // Resolver returns target; BFS returns one frontier caller; no STEP_IN_PROCESS rows. (executeParameterized as any).mockImplementation((_repoId: string, cypher: string) => { // BFS frontier query is now parameterized (#1907 U3). - if (cypher.includes('r.type IN') && !cypher.includes('STEP_IN_PROCESS')) { + if (cypher.includes('$frontierIds')) { return Promise.resolve([ { id: 'func:caller', @@ -2234,10 +2239,12 @@ describe('LocalBackend.callTool', () => { }, ]); } - // Symbol resolution. - return Promise.resolve([ - { id: 'func:main', name: 'main', type: 'Function', filePath: 'src/index.ts' }, - ]); + // Symbol resolution; unseeded enrichment queries return no rows. + return Promise.resolve( + cypher.includes('$symName') + ? [{ id: 'func:main', name: 'main', type: 'Function', filePath: 'src/index.ts' }] + : [], + ); }); (executeQuery as any).mockResolvedValue([]); @@ -2974,7 +2981,7 @@ describe('LocalBackend.callTool', () => { }); it('dispatches "explore" as alias for context', async () => { - (executeParameterized as any).mockResolvedValue([ + mockSymbolRows([ { id: 'func:main', name: 'main', @@ -3007,9 +3014,7 @@ describe('LocalBackend impact mode (KTD1/KTD5/KTD12)', () => { // dispatch (callgraph BFS or the PDG traversal). The callgraph BFS then issues // executeQuery for its frontier; the PDG path delegates to runImpactPDG. function resolveSingleTarget() { - (executeParameterized as any).mockResolvedValue([ - { id: 'func:main', name: 'main', type: 'Function', filePath: 'src/index.ts' }, - ]); + mockSymbolRows([{ id: 'func:main', name: 'main', type: 'Function', filePath: 'src/index.ts' }]); (executeQuery as any).mockResolvedValue([]); } @@ -3266,18 +3271,13 @@ describe('LocalBackend impact mode (KTD1/KTD5/KTD12)', () => { it("mode:'pdg' + downstream line:8 routes to the PDG traversal and seeds bridge evidence", async () => { resolveSingleTarget(); - // The target-resolution row doubles as the calleesOfBlocks row: `callees` - // ('callee') is the leaf name persisted on the slice's BasicBlock, the - // statement-precise substrate the bridge keys on. - (executeParameterized as any).mockResolvedValue([ - { - id: 'func:main', - name: 'main', - type: 'Function', - filePath: 'src/index.ts', - callees: 'callee', - }, - ]); + // BasicBlock callees and symbol lookup use distinct native projections. + vi.mocked(executeParameterized).mockImplementation(async (_repo, query) => { + if (query.includes('RETURN b.callees')) return [{ callees: 'callee' }]; + return query.includes('$symName') + ? [{ id: 'func:main', name: 'main', type: 'Function', filePath: 'src/index.ts' }] + : []; + }); // A line-seeded downstream slice with one reachable block → the dispatch // queries that block's callees and seeds the bridge with them. const pdgSpy = vi.spyOn(backend as any, '_runImpactPDG').mockResolvedValueOnce({ @@ -3402,11 +3402,13 @@ describe('LocalBackend impact mode (KTD1/KTD5/KTD12)', () => { // is not built and the inter-procedural reach falls back to callgraph-equal — // never surfacing the error or producing a partial proven/unproven labeling. resolveSingleTarget(); - // The slice-callees query (RETURN b.callees) throws; every other query (target - // resolution) returns the resolved symbol row. + // The slice-callees query throws; lookup returns the target and unseeded + // relationship/process projections return no rows. vi.mocked(executeParameterized).mockImplementation(async (_repo, query) => { if (query.includes('RETURN b.callees')) throw new Error('slice-callees query failed'); - return [{ id: 'func:main', name: 'main', type: 'Function', filePath: 'src/index.ts' }]; + return query.includes('$symName') + ? [{ id: 'func:main', name: 'main', type: 'Function', filePath: 'src/index.ts' }] + : []; }); // A line-seeded downstream slice so calleesOfBlocks is attempted. vi.spyOn(backend as any, '_runImpactPDG').mockResolvedValueOnce({ @@ -3461,7 +3463,9 @@ describe('LocalBackend impact mode (KTD1/KTD5/KTD12)', () => { resolveSingleTarget(); vi.mocked(executeParameterized).mockImplementation(async (_repo, query) => { if (query.includes('RETURN b.callees')) throw new Error('Table BasicBlock does not exist'); - return [{ id: 'func:main', name: 'main', type: 'Function', filePath: 'src/index.ts' }]; + return query.includes('$symName') + ? [{ id: 'func:main', name: 'main', type: 'Function', filePath: 'src/index.ts' }] + : []; }); vi.spyOn(backend as any, '_runImpactPDG').mockResolvedValueOnce({ mode: 'pdg', diff --git a/gitnexus/test/unit/impact-batching-grouping.test.ts b/gitnexus/test/unit/impact-batching-grouping.test.ts index e068870f9..7f4d18bd8 100644 --- a/gitnexus/test/unit/impact-batching-grouping.test.ts +++ b/gitnexus/test/unit/impact-batching-grouping.test.ts @@ -79,6 +79,8 @@ describe('impact: batching and grouping', () => { // Handle parameterized calls (including chunked STEP_IN_PROCESS queries) executeParameterizedMock.mockImplementation(async (...args: any[]) => { const query = typeof args[1] === 'string' ? args[1] : String(args[0] ?? ''); + if (query.includes('RETURN h.id AS hid')) return []; + if (query.includes('RETURN s.id AS sid')) return []; const params = args[2] || {}; // Match only the aggregation chunk (which uses COUNT(DISTINCT s.id)), // not the per-symbol enrichment pass added by impact byDepth processes @@ -91,6 +93,7 @@ describe('impact: batching and grouping', () => { const idx = chunkCallIndex++; return [ { + pId: 'proc-' + idx, entryPointId: `ep-${Math.floor(idx)}`, epName: `epName-${idx}`, epType: 'Function', @@ -148,6 +151,8 @@ describe('impact: batching and grouping', () => { executeParameterizedMock.mockImplementation(async (...args: any[]) => { const query = typeof args[1] === 'string' ? args[1] : String(args[0] ?? ''); + if (query.includes('RETURN h.id AS hid')) return []; + if (query.includes('RETURN s.id AS sid')) return []; // BFS frontier query (parameterized #1907 U3): return 6 impacted nodes. if (query.includes('r.type IN') && !query.includes('STEP_IN_PROCESS')) { const res: any[] = []; @@ -166,6 +171,7 @@ describe('impact: batching and grouping', () => { // For STEP_IN_PROCESS in this test, return grouping rows return [ { + pId: 'proc-1a', entryPointId: 'ep-1', epName: 'EP1', epType: 'Function', @@ -174,6 +180,7 @@ describe('impact: batching and grouping', () => { minStep: 1, }, { + pId: 'proc-2', entryPointId: 'ep-2', epName: 'EP2', epType: 'Function', @@ -182,6 +189,7 @@ describe('impact: batching and grouping', () => { minStep: 2, }, { + pId: 'proc-1b', entryPointId: 'ep-1', epName: 'EP1', epType: 'Function', @@ -190,6 +198,7 @@ describe('impact: batching and grouping', () => { minStep: 3, }, { + pId: 'proc-3', entryPointId: 'ep-3', epName: 'EP3', epType: 'Function', @@ -245,6 +254,8 @@ describe('impact: batching and grouping', () => { executeParameterizedMock.mockImplementation(async (...args: any[]) => { const query = typeof args[1] === 'string' ? args[1] : String(args[0] ?? ''); + if (query.includes('RETURN h.id AS hid')) return []; + if (query.includes('RETURN s.id AS sid')) return []; const params = args[2] || {}; // Match only the aggregation chunk (which uses COUNT(DISTINCT s.id)), // not the per-symbol enrichment pass added by impact byDepth processes @@ -254,6 +265,7 @@ describe('impact: batching and grouping', () => { chunkSizes.push(ids.length); return [ { + pId: 'proc-x-' + chunkSizes.length, entryPointId: 'ep-x', epName: 'EPX', epType: 'Function', @@ -351,6 +363,7 @@ describe('impact: batching and grouping', () => { executeQueryMock.mockImplementation(async () => []); executeParameterizedMock.mockImplementation(async (...args: any[]) => { const query = typeof args[1] === 'string' ? args[1] : String(args[0] ?? ''); + if (query.includes('RETURN h.id AS hid')) return []; if (query.includes('r.type IN') && !query.includes('STEP_IN_PROCESS')) { return [ { @@ -394,6 +407,7 @@ describe('impact: batching and grouping', () => { executeQueryMock.mockImplementation(async () => []); executeParameterizedMock.mockImplementation(async (...args: any[]) => { const query = typeof args[1] === 'string' ? args[1] : String(args[0] ?? ''); + if (query.includes('RETURN h.id AS hid')) return []; if (query.includes('STEP_IN_PROCESS')) { throw new Error('process chunk failed'); } @@ -443,6 +457,8 @@ describe('impact: batching and grouping', () => { executeQueryMock.mockImplementation(async () => []); executeParameterizedMock.mockImplementation(async (...args: any[]) => { const query = typeof args[1] === 'string' ? args[1] : String(args[0] ?? ''); + if (query.includes('RETURN h.id AS hid')) return []; + if (query.includes('RETURN s.id AS sid')) return []; if (query.includes('MEMBER_OF')) throw new Error('module chunk failed'); if (query.includes('STEP_IN_PROCESS') && query.includes('COUNT(DISTINCT s.id)')) { return [ @@ -500,6 +516,8 @@ describe('impact: batching and grouping', () => { executeQueryMock.mockImplementation(async () => []); executeParameterizedMock.mockImplementation(async (...args: any[]) => { const query = typeof args[1] === 'string' ? args[1] : String(args[0] ?? ''); + if (query.includes('RETURN h.id AS hid')) return []; + if (query.includes('RETURN s.id AS sid')) return []; if (query.includes('MIN(r.step) AS minStep') && !query.includes('COUNT(DISTINCT s.id)')) { throw new Error('minStep backfill failed'); } @@ -559,6 +577,8 @@ describe('impact: batching and grouping', () => { let processChunk = 0; executeParameterizedMock.mockImplementation(async (...args: any[]) => { const query = typeof args[1] === 'string' ? args[1] : String(args[0] ?? ''); + if (query.includes('RETURN h.id AS hid')) return []; + if (query.includes('RETURN s.id AS sid')) return []; if (query.includes('STEP_IN_PROCESS') && query.includes('COUNT(DISTINCT s.id)')) { processChunk += 1; if (processChunk === 2) throw new Error('later process chunk failed'); @@ -615,6 +635,7 @@ describe('impact: batching and grouping', () => { executeQueryMock.mockImplementation(async () => []); executeParameterizedMock.mockImplementation(async (...args: any[]) => { const query = typeof args[1] === 'string' ? args[1] : String(args[0] ?? ''); + if (query.includes('RETURN h.id AS hid')) return []; if (query.includes('MEMBER_OF') && query.includes('RETURN DISTINCT c.heuristicLabel')) { throw new Error('module classification failed'); } diff --git a/gitnexus/test/unit/impact-context-integrity.test.ts b/gitnexus/test/unit/impact-context-integrity.test.ts new file mode 100644 index 000000000..5ad29172b --- /dev/null +++ b/gitnexus/test/unit/impact-context-integrity.test.ts @@ -0,0 +1,847 @@ +/** Corrupt detail rows must not become usable context/impact answers (#3354). */ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { db, aop } = vi.hoisted(() => ({ + db: { + initLbug: vi.fn().mockResolvedValue(undefined), + executeQuery: vi.fn().mockResolvedValue([]), + executeParameterized: vi.fn().mockResolvedValue([]), + closeLbug: vi.fn().mockResolvedValue(undefined), + isLbugReady: vi.fn().mockReturnValue(true), + }, + aop: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('../../src/core/lbug/pool-adapter.js', async (importOriginal) => ({ + ...(await importOriginal()), + ...db, +})); +vi.mock('../../src/mcp/core/lbug-adapter.js', async (importOriginal) => ({ + ...(await importOriginal()), + ...db, +})); +vi.mock('../../src/storage/repo-manager.js', async (importOriginal) => ({ + ...(await importOriginal()), + listRegisteredRepos: vi.fn().mockResolvedValue([ + { + name: 'integrity-fixture', + path: '/tmp/integrity-fixture', + storagePath: '/tmp/integrity-fixture/.gitnexus', + indexedAt: '2026-10-03T12:00:00Z', + lastCommit: 'fixture', + stats: { files: 2, nodes: 2, edges: 1, communities: 0, processes: 1 }, + }, + ]), + cleanupOldKuzuFiles: vi.fn().mockResolvedValue({ found: false, needsReindex: false }), + findSiblingClones: vi.fn().mockResolvedValue([]), + loadMeta: vi.fn().mockResolvedValue({ + pdg: { maxCdgEdgesPerFunction: 0, maxReachingDefEdgesPerFunction: 0 }, + }), +})); +vi.mock('../../src/core/git-staleness.js', () => ({ + checkStalenessAsync: vi.fn().mockResolvedValue({ isStale: false, commitsBehind: 0 }), + checkStaleness: vi.fn().mockReturnValue({ isStale: false, commitsBehind: 0 }), + checkCwdMatch: vi.fn().mockResolvedValue({ match: 'none' }), +})); +vi.mock('../../src/storage/git.js', async (importOriginal) => ({ + ...(await importOriginal()), + getGitRoot: vi.fn().mockReturnValue(null), +})); +vi.mock('../../src/mcp/local/aop-metadata.js', () => ({ querySpringAopMetadata: aop })); + +import { LocalBackend } from '../../src/mcp/local/local-backend.js'; +import { SymbolIdentityError } from '../../src/mcp/local/query-result-integrity.js'; + +const TARGET = { + id: 'func:target', + name: 'target', + type: 'Function', + filePath: 'src/target.ts', + startLine: 1, + endLine: 4, +}; +const REF = { + relType: 'CALLS', + uid: 'func:caller', + name: 'caller', + filePath: 'src/caller.ts', + kind: 'Function', +}; +const EDGE = { + sourceId: TARGET.id, + id: REF.uid, + name: REF.name, + type: REF.kind, + filePath: REF.filePath, + relType: 'CALLS', + confidence: 1, +}; +const PROCESS = { + pId: 'proc:caller', + name: 'Caller flow', + processType: 'intra_community', + entryPointId: REF.uid, + hits: 1, + minStep: 0, + stepCount: 2, + epName: REF.name, + epType: REF.kind, + epFilePath: REF.filePath, +}; +const BAD = 'corrupt\0persisted-value'; + +type Seam = + | 'target' + | 'targetLabels' + | 'aopRows' + | 'pdgSeed' + | 'pdgNeighbor' + | 'pdgOwner' + | 'pdgStatement' + | 'pdgSelf' + | 'pdgCalleeBlocks' + | 'pdgSummary' + | 'pdgSpans' + | 'incoming' + | 'classIncoming' + | 'outgoing' + | 'typedProperties' + | 'contextProcess' + | 'contextRoute' + | 'interfaceBoundary' + | 'interfaceCount' + | 'chain' + | 'seeds' + | 'members' + | 'frontier' + | 'process' + | 'backfill' + | 'membership' + | 'modules' + | 'impactRoute' + | 'metadata'; +let backend: LocalBackend; +let rows: Partial>; +let failedSeam: Seam | undefined; + +function fixture(seam: Seam): unknown[] { + if (failedSeam === seam) throw new Error('ordinary unavailable query'); + return rows[seam] ?? []; +} + +function querySeam(query: string, params: Record | undefined): Seam | undefined { + if (params?.symName || params?.uid) return 'target'; + if (query.includes("RETURN n.id AS id, 'Class' AS label")) return 'targetLabels'; + if (query.includes("r.reason STARTS WITH 'spring-aop:v1:'")) return 'aopRows'; + if (query.includes('RETURN s.id AS id, s.name AS name')) return 'pdgOwner'; + if (query.includes('RETURN s.id AS id, s.filePath AS filePath')) return 'pdgSpans'; + if (query.includes('RETURN c.id AS id, r.reason AS reason')) return 'pdgSummary'; + if (query.includes('RETURN a.id AS id, r.reason AS reason')) return 'pdgSelf'; + if (query.includes('RETURN a.id AS id ORDER BY a.startLine')) return 'pdgSeed'; + if (query.includes('RETURN b.id AS id')) { + if (query.includes('b.calleeIds AS calleeIds')) return 'pdgCalleeBlocks'; + if (query.includes('b.text AS text')) return 'pdgStatement'; + return 'pdgSeed'; + } + if (query.includes('BasicBlock') && query.includes('RETURN DISTINCT')) return 'pdgNeighbor'; + if (query.includes('WITH DISTINCT caller') || query.includes('WITH DISTINCT target')) + return 'chain'; + if (query.includes('caller.id AS uid')) + return query.includes('(ctor:Constructor)') ? 'classIncoming' : 'incoming'; + if (query.includes('target.id AS uid')) return 'outgoing'; + if (query.includes('RETURN p.id AS uid')) return 'typedProperties'; + if (query.includes('RETURN p.id AS pid, p.heuristicLabel AS label')) return 'contextProcess'; + if (query.includes('RETURN route.name AS url')) return 'contextRoute'; + if (query.includes('RETURN DISTINCT iface.id AS id')) return 'interfaceBoundary'; + if (query.includes('RETURN COUNT(DISTINCT other.id) AS cnt')) return 'interfaceCount'; + if ( + query.includes('RETURN c.id AS id') || + query.includes('RETURN f.id AS id') || + query.includes('RETURN p.id AS id') + ) + return 'seeds'; + if (query.includes('RETURN DISTINCT member.id AS id')) return 'members'; + if (query.includes('AS sourceId')) return 'frontier'; + if (query.includes('RETURN p.id AS pId')) return 'process'; + if (query.includes('RETURN p.id AS pid, MIN(r.step) AS minStep')) return 'backfill'; + if (query.includes('RETURN s.id AS sid')) return 'membership'; + if (query.includes('c.heuristicLabel AS name')) return 'modules'; + if (query.includes('RETURN h.id AS hid')) return 'impactRoute'; + if (query.includes('n.visibility AS visibility')) return 'metadata'; + return undefined; +} + +async function context(extra = {}) { + return backend.callTool('context', { name: TARGET.name, ...extra }); +} +async function impact(extra = {}) { + return backend.callTool('impact', { + target: TARGET.name, + direction: 'upstream', + maxDepth: 1, + ...extra, + }); +} +function expectIntegrityError(result: any, isImpact = false) { + expect(result.error).toMatch(/invalid symbol identity/i); + expect(result.recoverySuggestion).toMatch(/analyze.*--force/); + expect(JSON.stringify(result)).not.toContain('persisted-value'); + expect(result).not.toHaveProperty('symbol'); + expect(result).not.toHaveProperty('incoming'); + if (isImpact) { + expect(result.risk).toBe('UNKNOWN'); + expect(result.impactedCount).toBeNull(); + expect(result.epistemic).not.toBe('exact'); + } +} +function tuple(value: Record, keys: string[]): unknown[] { + return keys.map((key) => value[key]); +} + +beforeEach(async () => { + vi.clearAllMocks(); + aop.mockResolvedValue(undefined); + failedSeam = undefined; + rows = { target: [{ ...TARGET }], frontier: [{ ...EDGE }] }; + db.executeParameterized.mockImplementation(async (_db, query, params) => { + const seam = querySeam(query, params); + return seam ? fixture(seam) : []; + }); + backend = new LocalBackend(); + await backend.init(); + vi.spyOn(backend as any, 'ensureInitialized').mockResolvedValue(undefined); + vi.spyOn(backend as any, 'computeEpistemicBoundary').mockResolvedValue({ epistemic: 'exact' }); +}); + +describe('identity corruption before target selection', () => { + for (const corrupt of [true, false]) { + it('distinguishes corrupt and ordinary ambiguous candidate failures: ' + corrupt, async () => { + rows.target = [ + { ...TARGET, id: 'func:one', filePath: 'src/one.ts' }, + { ...TARGET, id: 'func:two', filePath: 'src/two.ts' }, + ]; + vi.spyOn(backend as any, '_runImpactBFS').mockRejectedValue( + corrupt ? new SymbolIdentityError() : new Error('ordinary candidate failure'), + ); + const result = await impact(); + if (corrupt) { + expectIntegrityError(result, true); + } else { + expect(result.error).toBeUndefined(); + expect(result.status).toBe('ambiguous'); + expect(result.partialProbe).toBe(true); + } + }); + } + for (const tool of ['context', 'impact']) { + for (const badRow of [ + { id: BAD, label: 'Class' }, + { id: '', label: 'Class' }, + { id: 42, label: 'Class' }, + { id: TARGET.id, label: BAD }, + ]) { + it('rejects corrupt label enrichment for ' + tool + JSON.stringify(badRow), async () => { + rows.target = [{ ...TARGET, type: '' }]; + rows.targetLabels = [badRow, { id: TARGET.id, label: 'Class' }]; + expectIntegrityError( + tool === 'context' ? await context() : await impact(), + tool === 'impact', + ); + }); + } + } + for (const shape of ['object', 'tuple']) { + for (const field of ['name', 'filePath']) { + for (const tool of ['context', 'impact', 'pdg impact']) { + it('rejects NUL in target ' + field + ' from ' + shape + ' rows for ' + tool, async () => { + const target = { ...TARGET, [field]: BAD }; + rows.target = [ + shape === 'tuple' + ? tuple(target, ['id', 'name', 'type', 'filePath', 'startLine', 'endLine']) + : target, + ]; + expectIntegrityError( + tool === 'context' + ? await context() + : await impact(tool === 'pdg impact' ? { mode: 'pdg' } : {}), + tool !== 'context', + ); + }); + } + } + } + it('rejects corrupt exact-UID metadata before expansion', async () => { + rows.target = [{ ...TARGET, filePath: BAD }]; + expectIntegrityError(await context({ uid: TARGET.id })); + expectIntegrityError(await impact({ target_uid: TARGET.id }), true); + }); + for (const field of ['name', 'filePath']) { + it('rejects non-string target ' + field, async () => { + rows.target = [{ ...TARGET, [field]: 42 }]; + expectIntegrityError(await context()); + }); + } + it('validates every candidate before exact File narrowing', async () => { + rows.target = [ + { ...TARGET, id: 'File:src/target.ts', name: 'target.ts', type: 'File' }, + { ...TARGET, id: 'func:other', filePath: BAD }, + ]; + expectIntegrityError(await context({ name: TARGET.filePath })); + }); +}); + +describe('context detail row integrity', () => { + for (const seam of ['incoming', 'outgoing'] as const) { + for (const shape of ['object', 'tuple']) { + for (const field of ['uid', 'name', 'filePath']) { + it('rejects NUL in ' + seam + ' ' + field + ' from ' + shape + ' rows', async () => { + const ref = { ...REF, [field]: BAD }; + rows[seam] = [ + shape === 'tuple' ? tuple(ref, ['relType', 'uid', 'name', 'filePath', 'kind']) : ref, + ]; + expectIntegrityError(await context()); + }); + } + for (const badRow of [null, {}, [], { ...REF, uid: '' }, { ...REF, relType: '' }]) { + it( + 'rejects incomplete ' + + seam + + ' row ' + + JSON.stringify(badRow) + + ' in ' + + shape + + ' response', + async () => { + rows[seam] = [ + shape === 'tuple' && badRow !== null + ? tuple(badRow, ['relType', 'uid', 'name', 'filePath', 'kind']) + : badRow, + ]; + expectIntegrityError(await context()); + }, + ); + } + } + } + for (const badRow of [ + null, + {}, + [''], + { pid: '' }, + { pid: 'proc:target', label: BAD }, + ['proc:target', BAD, 0, 0], + ]) { + it('rejects corrupt context process ' + JSON.stringify(badRow), async () => { + rows.contextProcess = [badRow]; + expectIntegrityError(await context()); + }); + } + it('does not swallow corrupt class expansion or typed property rows', async () => { + rows.target = [{ ...TARGET, type: 'Class' }]; + rows.typedProperties = [{ uid: 'prop:target', name: 'prop', filePath: BAD, kind: 'Property' }]; + expectIntegrityError(await context()); + }); + it('rejects corrupt class refs before deduplication can discard them', async () => { + rows.target = [{ ...TARGET, type: 'Class' }]; + rows.incoming = [REF]; + rows.classIncoming = [{ ...REF, filePath: BAD }]; + expectIntegrityError(await context()); + }); + for (const badRow of [{}, { ...REF, filePath: BAD }, { ...REF, uid: '' }]) { + it('does not swallow corrupt chain rows ' + JSON.stringify(badRow), async () => { + rows.chain = [badRow]; + expectIntegrityError(await context({ chain_depth: 1 })); + }); + } + it('rejects NUL in route names', async () => { + rows.contextRoute = [{ url: BAD, method: 'GET' }]; + expectIntegrityError(await context()); + }); + it('rejects nested AOP identity fields while keeping the shared error envelope', async () => { + aop.mockResolvedValue({ + framework: 'spring', + advices: [{ adviceId: 'advice:1', adviceName: BAD }], + }); + expectIntegrityError(await context()); + }); +}); + +describe('epistemic boundary row integrity', () => { + const iface = { id: 'iface:target', name: 'Target contract', label: 'Interface' }; + + function prepareBoundary() { + vi.mocked((backend as any).computeEpistemicBoundary).mockRestore(); + rows.interfaceBoundary = [iface]; + rows.interfaceCount = [{ cnt: 2 }]; + } + + for (const tool of ['context', 'impact']) { + for (const shape of ['object', 'tuple']) { + for (const badRow of [ + { ...iface, id: undefined }, + { ...iface, id: '' }, + { ...iface, id: BAD }, + { ...iface, id: 42 }, + { ...iface, name: BAD }, + { ...iface, name: 42 }, + { ...iface, label: BAD }, + { ...iface, label: 42 }, + ]) { + it( + 'rejects corrupt ' + + tool + + ' boundary before deduplication: ' + + shape + + JSON.stringify(badRow), + async () => { + prepareBoundary(); + rows.interfaceBoundary = [iface, badRow].map((row) => + shape === 'tuple' ? tuple(row, ['id', 'name', 'label']) : row, + ); + expectIntegrityError( + tool === 'context' ? await context() : await impact(), + tool === 'impact', + ); + }, + ); + } + } + for (const seam of ['interfaceBoundary', 'interfaceCount'] as const) { + for (const corrupt of [true, false]) { + it( + 'distinguishes ' + tool + ' boundary query failure: ' + seam + ' ' + corrupt, + async () => { + prepareBoundary(); + db.executeParameterized.mockImplementation(async (_db, query, params) => { + const currentSeam = querySeam(query, params); + if (currentSeam === seam) { + throw corrupt ? new SymbolIdentityError() : new Error('ordinary boundary failure'); + } + return currentSeam ? fixture(currentSeam) : []; + }); + const result = tool === 'context' ? await context() : await impact(); + if (corrupt) { + expectIntegrityError(result, tool === 'impact'); + } else { + expect(result.error).toBeUndefined(); + expect(result.recoverySuggestion).toBeUndefined(); + expect(result.epistemic).toBe('lower-bound'); + if (tool === 'impact') expect(result.impactedCount).toBe(1); + } + }, + ); + } + } + it('preserves healthy ' + tool + ' boundary descriptions', async () => { + prepareBoundary(); + const result = tool === 'context' ? await context() : await impact(); + expect(result.error).toBeUndefined(); + expect(result.epistemic).toBe('lower-bound'); + expect(result.boundaries).toContainEqual( + expect.stringContaining('Target contract is an interface'), + ); + expect(result.causes.dispatchBoundary).toBe(4); + }); + } +}); + +describe('impact detail row integrity', () => { + for (const seam of ['frontier', 'process'] as const) { + it( + 'PDG detail integrity rejects corrupt ' + seam + ' rows through the outer envelope', + async () => { + rows[seam] = [ + seam === 'frontier' ? { ...EDGE, filePath: BAD } : { ...PROCESS, epName: BAD }, + ]; + expectIntegrityError(await impact({ mode: 'pdg' }), true); + }, + ); + } + for (const shape of ['object', 'tuple']) { + for (const field of ['id', 'name', 'filePath', 'sourceId']) { + it('rejects NUL in frontier ' + field + ' from ' + shape + ' rows', async () => { + const edge = { ...EDGE, [field]: BAD }; + rows.frontier = [ + shape === 'tuple' + ? tuple(edge, [ + 'sourceId', + 'id', + 'name', + 'type', + 'filePath', + 'relType', + 'confidence', + 'staticGated', + ]) + : edge, + ]; + expectIntegrityError(await impact(), true); + }); + } + } + for (const badRow of [null, {}, [], { ...EDGE, id: '' }]) { + it('rejects incomplete frontier rows ' + JSON.stringify(badRow), async () => { + rows.frontier = [badRow]; + expectIntegrityError(await impact(), true); + }); + } + it('validates corrupt rows before test filtering', async () => { + rows.frontier = [{ ...EDGE, filePath: 'test/corrupt\0.test.ts' }]; + expectIntegrityError(await impact({ includeTests: false }), true); + }); + for (const shape of ['object', 'tuple']) { + for (const field of ['pId', 'name', 'entryPointId', 'epName', 'epFilePath']) { + it('rejects NUL in process ' + field + ' from ' + shape + ' rows', async () => { + const process = { ...PROCESS, [field]: BAD }; + rows.process = [ + shape === 'tuple' + ? tuple(process, [ + 'pId', + 'name', + 'processType', + 'entryPointId', + 'hits', + 'minStep', + 'stepCount', + 'epName', + 'epType', + 'epFilePath', + ]) + : process, + ]; + expectIntegrityError(await impact({ summaryOnly: true }), true); + }); + } + } + for (const badRow of [null, {}, [], { ...PROCESS, pId: '' }]) { + it('does not aggregate incomplete processes ' + JSON.stringify(badRow), async () => { + rows.process = [badRow]; + expectIntegrityError(await impact(), true); + }); + } + for (const badRow of [{}, { pid: BAD, minStep: 1 }]) { + it( + 'does not swallow corrupt backfill process identities ' + JSON.stringify(badRow), + async () => { + rows.process = [{ ...PROCESS, minStep: null }]; + rows.backfill = [badRow]; + expectIntegrityError(await impact(), true); + }, + ); + } + for (const badRow of [ + {}, + { sid: REF.uid, pid: '' }, + { sid: REF.uid, pid: 'proc:caller', pName: BAD }, + ]) { + it( + 'does not swallow corrupt per-symbol process identities ' + JSON.stringify(badRow), + async () => { + rows.process = [PROCESS]; + rows.membership = [badRow]; + expectIntegrityError(await impact(), true); + }, + ); + } + for (const type of ['Class', 'Const']) { + for (const badRow of [{}, { ...TARGET, id: 'seed:1', filePath: BAD }]) { + it('rejects corrupt ' + type + ' seeds ' + JSON.stringify(badRow), async () => { + rows.target = [{ ...TARGET, type }]; + rows[type === 'Class' ? 'seeds' : 'members'] = [badRow]; + expectIntegrityError(await impact({ direction: 'downstream' }), true); + }); + } + } + it('rejects NUL in module names before aggregation', async () => { + rows.modules = [{ name: BAD, hits: 1 }]; + expectIntegrityError(await impact(), true); + }); + it('rejects NUL in route names', async () => { + rows.impactRoute = [{ hid: REF.uid, url: BAD }]; + expectIntegrityError(await impact(), true); + }); + for (const shape of ['object', 'tuple']) { + for (const hid of [undefined, null, '', ' ', BAD, 42, {}]) { + it( + 'rejects corrupt route handler IDs from ' + shape + ' rows: ' + JSON.stringify(hid), + async () => { + const route = { hid, url: '/target', method: 'GET' }; + rows.impactRoute = [shape === 'tuple' ? tuple(route, ['hid', 'url', 'method']) : route]; + expectIntegrityError(await impact(), true); + }, + ); + } + } + it('rejects nested AOP paths', async () => { + aop.mockResolvedValue({ + framework: 'spring', + advices: [{ adviceId: 'advice:1', adviceFilePath: BAD }], + }); + expectIntegrityError(await impact(), true); + }); +}); + +describe('healthy and ordinary-failure compatibility', () => { + it('preserves Unicode, opaque IDs, optional NULL metadata and source NUL', async () => { + const content = 'const value = "actual\0source";'; + rows.target = [{ ...TARGET, name: 'café�', filePath: '源/café�.ts', content }]; + rows.incoming = [{ ...REF, name: '呼び出し�', filePath: null, kind: '' }]; + rows.contextProcess = [ + { pid: 'legacy:proc ', label: '', step: 0, stepCount: 0, entryPointId: null }, + ]; + rows.metadata = [{ annotations: ['@Text("source\0value")'], parameterTypes: null }]; + const result = await context({ include_content: true }); + expect(result.error).toBeUndefined(); + expect(result.symbol).toMatchObject({ + uid: TARGET.id, + name: 'café�', + filePath: '源/café�.ts', + content, + }); + expect(result.symbol.methodMetadata).toEqual({ annotations: ['@Text("source\0value")'] }); + expect(result.incoming.calls[0]).toMatchObject({ uid: REF.uid, name: '呼び出し�' }); + expect(result.processes).toEqual([ + { id: 'legacy:proc ', name: undefined, step_index: 0, step_count: 0 }, + ]); + }); + it('preserves tuple zero steps and empty process labels', async () => { + rows.contextProcess = [['proc:0', '', 0, 0, null]]; + expect((await context()).processes).toEqual([ + { id: 'proc:0', name: '', step_index: 0, step_count: 0 }, + ]); + }); + it('allows absent OPTIONAL MATCH entry-point fields and missing legacy sourceId', async () => { + rows.frontier = [{ ...EDGE, sourceId: undefined }]; + rows.process = [ + { ...PROCESS, name: '', entryPointId: null, epName: null, epType: null, epFilePath: null }, + ]; + const result = await impact(); + expect(result.error).toBeUndefined(); + expect(result.impactedCount).toBe(1); + expect(result.affected_processes).toEqual([ + { + name: 'unknown', + type: 'Function', + filePath: '', + affected_process_count: 1, + total_hits: 1, + earliest_broken_step: 0, + }, + ]); + }); + it('keeps missing optional route fields as ordinary skipped enrichment', async () => { + rows.contextRoute = [{}]; + rows.impactRoute = [{ hid: REF.uid }]; + expect((await context()).error).toBeUndefined(); + expect((await impact()).error).toBeUndefined(); + }); + it('does not misdiagnose an unmatched user-supplied NUL as index corruption', async () => { + rows.target = []; + const contextResult = await context({ name: 'client\0input' }); + const impactResult = await impact({ target: 'client\0input' }); + expect(contextResult.error).toContain('not found'); + expect(impactResult.error).toContain('not found'); + expect(contextResult.recoverySuggestion).toBeUndefined(); + expect(impactResult.recoverySuggestion).toBeUndefined(); + }); + it('keeps ordinary process query failures degraded rather than integrity errors', async () => { + failedSeam = 'process'; + const result = await impact(); + expect(result.error).toBeUndefined(); + expect(result.partial).toBe(true); + expect(result.impactedCount).toBe(1); + expect(result.affected_processes).toEqual([]); + }); + it('keeps ordinary PDG interprocedural failures as degraded results', async () => { + vi.spyOn(backend as any, '_runImpactBFS').mockRejectedValue( + new Error('ordinary bridge failure'), + ); + const result = await impact({ mode: 'pdg' }); + expect(result.error).toBeUndefined(); + expect(result.partial).toBe(true); + expect(result.interproceduralError).toBe('ordinary bridge failure'); + expect(result.recoverySuggestion).toBeUndefined(); + }); + it('keeps ordinary context process query failures as unavailable enrichment', async () => { + failedSeam = 'contextProcess'; + const result = await context(); + expect(result.error).toBeUndefined(); + expect(result.processes).toEqual([]); + }); +}); + +describe('raw PDG identities before coercion, caps, and projection', () => { + const seed = 'BasicBlock:src/target.ts:2:0:0'; + const reached = 'BasicBlock:src/caller.ts:1:0:0'; + + function preparePdg() { + rows.pdgSeed = [{ id: seed }]; + rows.pdgNeighbor = [{ id: reached }]; + rows.pdgOwner = [{ id: REF.uid, name: REF.name, label: 'Function', startLine: 0 }]; + rows.pdgStatement = [{ id: reached, line: 1, endLine: 1, text: 'value = 1;' }]; + } + + for (const seam of ['pdgSeed', 'pdgNeighbor', 'pdgOwner', 'pdgStatement'] as const) { + for (const bad of [BAD, '', null, 42]) { + it('rejects raw ' + seam + ' identity ' + JSON.stringify(bad), async () => { + preparePdg(); + rows[seam] = [{ id: bad, name: 'caller', label: 'Function', line: 1, startLine: 0 }]; + expectIntegrityError( + await impact({ mode: 'pdg', ...(seam === 'pdgStatement' ? { line: 2 } : {}) }), + true, + ); + }); + } + } + for (const seam of ['pdgSeed', 'pdgNeighbor'] as const) { + it('validates ' + seam + ' cap probe rows', async () => { + preparePdg(); + rows[seam] = [{ id: seam === 'pdgSeed' ? seed : reached }, { id: BAD }]; + expectIntegrityError(await impact({ mode: 'pdg', limit: 1 }), true); + }); + } + for (const field of ['name', 'label']) { + it('rejects raw owner ' + field + ' before String coercion', async () => { + preparePdg(); + rows.pdgOwner = [{ id: REF.uid, name: 'caller', label: 'Function', [field]: BAD }]; + expectIntegrityError(await impact({ mode: 'pdg' }), true); + }); + } + for (const field of ['seedBlocks', 'reachableBlocks', 'intraReachableBlocks']) { + for (const bad of [BAD, '', null, 42]) { + it('rejects malformed final ' + field + ' members ' + JSON.stringify(bad), async () => { + const healthy = await impact({ mode: 'pdg' }); + vi.spyOn(backend as any, '_runImpactPDG').mockResolvedValue({ + ...healthy, + [field]: [bad], + }); + expectIntegrityError(await impact({ mode: 'pdg' }), true); + }); + } + } + it('allows a generated unresolved owner marker', async () => { + preparePdg(); + rows.pdgOwner = []; + const result = await impact({ mode: 'pdg' }); + expect(result.error).toBeUndefined(); + expect(result.unresolvedBlockCount).toBe(1); + }); + it('preserves Unicode owner tuples and source NUL', async () => { + preparePdg(); + rows.pdgOwner = [[REF.uid, '呼び出し�', 'Function', 0]]; + expect((await impact({ mode: 'pdg' })).error).toBeUndefined(); + rows.pdgStatement = [{ id: reached, line: 1, endLine: 1, text: 'value = "source\0text";' }]; + const result = await impact({ mode: 'pdg', line: 2 }); + expect(result.error).toBeUndefined(); + expect(result.affectedStatements.some((s: any) => s.text.includes('\0'))).toBe(true); + }); + for (const field of ['callees', 'calleeIds']) { + it('does not swallow a corrupt statement bridge ' + field, async () => { + preparePdg(); + const original = db.executeParameterized.getMockImplementation()!; + db.executeParameterized.mockImplementation(async (...args) => { + if (args[1].includes('RETURN b.' + field + ' AS ' + field)) { + return [{ [field]: BAD }]; + } + return original(...args); + }); + expectIntegrityError(await impact({ mode: 'pdg', direction: 'downstream' }), true); + }); + } +}); + +describe('real AOP helper identities before deduplication', () => { + const reason = + 'spring-aop:v1:' + + JSON.stringify({ + kind: 'advice', + annotation: 'org.aspectj.lang.annotation.Around', + advice: 'around', + pointcut: 'execution(*)', + match: 'static', + activation: 'unknown', + proxy: 'possible', + }); + const advice = { + sourceId: TARGET.id, + sourceName: 'target', + sourceFilePath: TARGET.filePath, + targetId: 'advice:1', + targetName: 'audit', + targetFilePath: 'src/audit.ts', + reason, + }; + async function useRealAop() { + const actual = await vi.importActual( + '../../src/mcp/local/aop-metadata.js', + ); + aop.mockImplementation(actual.querySpringAopMetadata); + rows.target = [{ ...TARGET, type: 'Method' }]; + } + for (const tool of ['context', 'impact']) { + for (const field of ['sourceId', 'targetId']) { + for (const bad of [BAD, '', null, 42]) { + it('rejects raw AOP ' + field + ' for ' + tool + JSON.stringify(bad), async () => { + await useRealAop(); + rows.aopRows = [{ ...advice, [field]: bad }, advice]; + expectIntegrityError( + tool === 'context' ? await context() : await impact(), + tool === 'impact', + ); + }); + } + } + for (const field of ['sourceName', 'sourceFilePath', 'targetName', 'targetFilePath']) { + it('rejects corrupt duplicate AOP ' + field + ' for ' + tool, async () => { + await useRealAop(); + rows.aopRows = [{ ...advice, [field]: BAD }, advice]; + expectIntegrityError( + tool === 'context' ? await context() : await impact(), + tool === 'impact', + ); + }); + } + } + it('validates the AOP cap-probe row', async () => { + await useRealAop(); + rows.aopRows = [...Array.from({ length: 1000 }, () => advice), { ...advice, targetId: BAD }]; + expectIntegrityError(await context()); + }); + it('preserves Unicode and optional NULL metadata', async () => { + await useRealAop(); + rows.aopRows = [ + { ...advice, sourceName: '源�', sourceFilePath: null, targetName: '', targetFilePath: null }, + ]; + const result = await context(); + expect(result.error).toBeUndefined(); + expect(result.symbol.aop.advices[0]).toMatchObject({ + adviceId: advice.targetId, + advisedId: advice.sourceId, + advisedName: '源�', + }); + }); + it('keeps ordinary AOP query failures fail-soft', async () => { + await useRealAop(); + failedSeam = 'aopRows'; + expect((await context()).error).toBeUndefined(); + expect((await impact()).error).toBeUndefined(); + }); + it('handles early AOP rejection while the frontier is pending', async () => { + const unhandled = vi.fn(); + process.on('unhandledRejection', unhandled); + aop.mockRejectedValue(new SymbolIdentityError()); + const original = db.executeParameterized.getMockImplementation()!; + db.executeParameterized.mockImplementation(async (...args) => { + if (querySeam(args[1], args[2]) === 'frontier') { + await new Promise((resolve) => setTimeout(resolve, 30)); + } + return original(...args); + }); + try { + expectIntegrityError(await impact(), true); + expect(unhandled).not.toHaveBeenCalled(); + } finally { + process.off('unhandledRejection', unhandled); + } + }); +}); diff --git a/gitnexus/test/unit/impact-pagination.test.ts b/gitnexus/test/unit/impact-pagination.test.ts index 9b8aa8e21..0a710a71e 100644 --- a/gitnexus/test/unit/impact-pagination.test.ts +++ b/gitnexus/test/unit/impact-pagination.test.ts @@ -56,6 +56,7 @@ function setupMultiDepthHub(d1Count: number, d2Count: number) { const query = typeof args[1] === 'string' ? args[1] : String(args[0] ?? ''); if (query.includes('STEP_IN_PROCESS')) return []; if (query.includes('MEMBER_OF')) return []; + if (query.includes('RETURN h.id AS hid')) return []; // The #1858 epistemic-boundary probe (computeEpistemicBoundary) runs // concurrently with the BFS and also matches `r.type IN`, but targets the // `iface` alias. Return empty so it stays `epistemic: 'exact'` and does not @@ -99,6 +100,7 @@ function setupHubSymbol(count: number) { const query = typeof args[1] === 'string' ? args[1] : String(args[0] ?? ''); if (query.includes('STEP_IN_PROCESS')) return []; if (query.includes('MEMBER_OF')) return []; + if (query.includes('RETURN h.id AS hid')) return []; // See setupMultiDepthHub — keep the #1858 epistemic probe from matching the // `r.type IN` caller branch below. if (query.includes('iface')) return []; diff --git a/gitnexus/test/unit/impact-route-enrichment.test.ts b/gitnexus/test/unit/impact-route-enrichment.test.ts index 9e9cc8791..d2b53031c 100644 --- a/gitnexus/test/unit/impact-route-enrichment.test.ts +++ b/gitnexus/test/unit/impact-route-enrichment.test.ts @@ -80,7 +80,10 @@ async function runImpact(routeRows: readonly RouteRow[], routeQueryFails = false : []; } if (query.includes('STEP_IN_PROCESS') || query.includes('MEMBER_OF')) return []; - return [{ id: 'svc', name: 'UnfinalizeRound', filePath: 'svc.go', type: 'Method' }]; + if (query.includes('n.id AS id')) { + return [{ id: 'svc', name: 'UnfinalizeRound', filePath: 'svc.go', type: 'Method' }]; + } + return []; }); const backend = new LocalBackend(); diff --git a/gitnexus/test/unit/pdg-impact-engine.test.ts b/gitnexus/test/unit/pdg-impact-engine.test.ts index e163df4be..3ca021e0b 100644 --- a/gitnexus/test/unit/pdg-impact-engine.test.ts +++ b/gitnexus/test/unit/pdg-impact-engine.test.ts @@ -1,12 +1,95 @@ import { describe, expect, it } from 'vitest'; import { IMPACT_MAX_DEPTH } from '../../src/mcp/tools.js'; +import { CALLEES_TRUNCATED_SENTINEL } from '../../src/core/ingestion/cfg/callee-cell-format.js'; import { pdgLayerStatus, runImpactPDG, + splitCalleeIds, type RunPdgImpactDeps, } from '../../src/mcp/local/pdg-impact.js'; +import { SymbolIdentityError } from '../../src/mcp/local/query-result-integrity.js'; + +describe('splitCalleeIds', () => { + const firstId = 'Function:src/my dir/rené.cpp:unsigned char'; + const secondId = 'Function:src/my dir/rené.cpp:long double'; + + it.each([undefined, null, '', ' ', '\t', '\t \t'])( + 'preserves an entirely empty optional cell (%j)', + (cell) => { + expect(splitCalleeIds(cell)).toEqual([]); + }, + ); + + it('preserves spaces and Unicode within healthy callee identities', () => { + expect(splitCalleeIds(`${firstId}\t${secondId}`)).toEqual([firstId, secondId]); + }); + + it('drops the generated truncation sentinel without changing resolved identities', () => { + expect(splitCalleeIds(CALLEES_TRUNCATED_SENTINEL)).toEqual([]); + expect(splitCalleeIds(`${firstId}\t${CALLEES_TRUNCATED_SENTINEL}\t${secondId}`)).toEqual([ + firstId, + secondId, + ]); + }); + + it.each([ + ['leading', `\t${firstId}`], + ['interior', `${firstId}\t\t${secondId}`], + ['trailing', `${firstId}\t`], + ['whitespace-only token', `${firstId}\t \t${secondId}`], + ['before a sentinel', `\t${CALLEES_TRUNCATED_SENTINEL}`], + ['after a sentinel', `${CALLEES_TRUNCATED_SENTINEL}\t`], + ['mixed with a sentinel', `${firstId}\t\t${CALLEES_TRUNCATED_SENTINEL}\t${secondId}`], + ])('rejects a populated cell with an empty identity (%s)', (_case, cell) => { + expect(() => splitCalleeIds(cell)).toThrow(SymbolIdentityError); + }); +}); describe('runImpactPDG', () => { + it.each([ + ['leading', '\tFunction:src/hot.ts:callee'], + ['interior', 'Function:src/hot.ts:a\t\tFunction:src/hot.ts:b'], + ['trailing', 'Function:src/hot.ts:callee\t'], + ['sentinel-adjacent', `Function:src/hot.ts:callee\t${CALLEES_TRUNCATED_SENTINEL}\t`], + ])( + 'rejects an empty callee identity before interprocedural descent (%s)', + async (_case, cell) => { + const seed = 'BasicBlock:src/hot.ts:1:0:0'; + const queries: string[] = []; + const exec: RunPdgImpactDeps['executeParameterized'] = async (_repo, query) => { + queries.push(query); + if (query.includes('MATCH (a:BasicBlock) WHERE')) return [{ id: seed }]; + if (query.includes('RETURN b.id AS id, b.calleeIds AS calleeIds')) { + return [{ id: seed, calleeIds: cell, callees: 'callee' }]; + } + return []; + }; + + await expect( + runImpactPDG({ + repo: { lbugPath: 'repo' }, + sym: { + id: 'Function:src/hot.ts:hot', + name: 'hot', + filePath: 'src/hot.ts', + startLine: 0, + endLine: 3, + }, + symType: 'Function', + direction: 'downstream', + maxDepth: 2, + limit: 50, + line: 1, + executeParameterized: exec, + }), + ).rejects.toBeInstanceOf(SymbolIdentityError); + expect( + queries.some((query) => query.includes('RETURN b.id AS id, b.calleeIds AS calleeIds')), + ).toBe(true); + expect(queries.some((query) => query.includes("r.type = 'CALL_SUMMARY'"))).toBe(false); + }, + ); + it('clamps huge maxDepth values to the documented impact traversal cap', async () => { let bfsQueries = 0; const exec = async (_repo: string, query: string) => { diff --git a/gitnexus/vitest.config.ts b/gitnexus/vitest.config.ts index 8bb6ff502..37c5889f3 100644 --- a/gitnexus/vitest.config.ts +++ b/gitnexus/vitest.config.ts @@ -68,6 +68,7 @@ export default defineConfig({ include: [ 'test/integration/skip-fts.test.ts', 'test/integration/impact-callable-value-references.test.ts', + 'test/integration/impact-context-integrity.test.ts', 'test/integration/impact-epistemic-lower-bound.test.ts', 'test/integration/impact-scope-omission-persistence.test.ts', 'test/integration/lbug-core-adapter.test.ts', @@ -162,6 +163,7 @@ export default defineConfig({ exclude: [ 'test/integration/skip-fts.test.ts', 'test/integration/impact-callable-value-references.test.ts', + 'test/integration/impact-context-integrity.test.ts', 'test/integration/impact-epistemic-lower-bound.test.ts', 'test/integration/impact-scope-omission-persistence.test.ts', 'test/integration/lbug-core-adapter.test.ts',