mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-06 02:49:56 +00:00
fix(mcp): reject corrupt impact and context identities (#3466)
This commit is contained in:
parent
16d7e9477b
commit
1a5d88391c
13 changed files with 2080 additions and 92 deletions
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string, string>();
|
||||
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<any> {
|
||||
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<string>();
|
||||
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<any> {
|
||||
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<string>([symId]);
|
||||
const pdgBridgeEvidenceById = new Map<string, PdgBridgeEvidenceInfo>();
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 `<fnLine>` 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<Record<string, unknown>>) {
|
||||
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<Record<string, unknown>>) {
|
||||
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<Record<string, unknown>>) {
|
||||
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<Record<string, unknown>>;
|
||||
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<Record<string, unknown>>) {
|
||||
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<Record<string, unknown>>) {
|
||||
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<Record<string, unknown>>) {
|
||||
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<PdgImpactRes
|
|||
seedBlockQuery(anchorClause, probeLimit),
|
||||
queryParams,
|
||||
);
|
||||
for (const row of rawSeedRows) assertQueryIdentity(row, 'id', 0);
|
||||
const seedRows = rawSeedRows.slice(0, stepLimit) as Array<Record<string, unknown>>;
|
||||
let seedBlocks: string[] = seedRows
|
||||
.map((r) => String(r['id'] ?? ''))
|
||||
|
|
|
|||
57
gitnexus/src/mcp/local/query-result-integrity.ts
Normal file
57
gitnexus/src/mcp/local/query-result-integrity.ts
Normal file
|
|
@ -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<string, unknown>;
|
||||
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<readonly [string, number]> = [],
|
||||
): 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;
|
||||
}
|
||||
758
gitnexus/test/integration/impact-context-integrity.test.ts
Normal file
758
gitnexus/test/integration/impact-context-integrity.test.ts
Normal file
|
|
@ -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<ReturnType<LocalBackend['callTool']>>,
|
||||
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<ReturnType<LocalBackend['callTool']>>,
|
||||
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<ReturnType<typeof createTempDir>>;
|
||||
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<ReturnType<LocalBackend['callTool']>>,
|
||||
context: Awaited<ReturnType<LocalBackend['callTool']>>,
|
||||
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();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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', {
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>[]) {
|
||||
(executeParameterized as any).mockImplementation(
|
||||
async (_repo: string, query: string, params: Record<string, unknown>) => {
|
||||
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',
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
|
|
|||
847
gitnexus/test/unit/impact-context-integrity.test.ts
Normal file
847
gitnexus/test/unit/impact-context-integrity.test.ts
Normal file
|
|
@ -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<typeof import('../../src/storage/repo-manager.js')>()),
|
||||
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<typeof import('../../src/storage/git.js')>()),
|
||||
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<Record<Seam, unknown[]>>;
|
||||
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<string, any> | 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<string, unknown>, 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é<66>', filePath: '源/café<66>.ts', content }];
|
||||
rows.incoming = [{ ...REF, name: '呼び出し<E587BA>', 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é<66>',
|
||||
filePath: '源/café<66>.ts',
|
||||
content,
|
||||
});
|
||||
expect(result.symbol.methodMetadata).toEqual({ annotations: ['@Text("source\0value")'] });
|
||||
expect(result.incoming.calls[0]).toMatchObject({ uid: REF.uid, name: '呼び出し<E587BA>' });
|
||||
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, '呼び出し<E587BA>', '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<typeof import('../../src/mcp/local/aop-metadata.js')>(
|
||||
'../../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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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 [];
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue