fix(ingestion): preserve object handler identity (#3046)
Some checks are pending
CodeQL / Analyze (javascript-typescript) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
Gitleaks / gitleaks (push) Waiting to run
Publish / Classify release event (push) Waiting to run
Publish / RC guard (marker + release-PR skip) (push) Blocked by required conditions
Publish / ci (push) Blocked by required conditions
Publish / Publish to npm (push) Blocked by required conditions
Publish / Build & Push RC Docker images (push) Blocked by required conditions
Scorecard / Scorecard analysis (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-cli) (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-web) (push) Waiting to run

* fix(ingestion): preserve object handler identity

* fix(impact): cap object callable expansion
This commit is contained in:
azizur100389 2026-08-26 15:44:33 +01:00 committed by GitHub
parent 09322d2d89
commit ac68f5254c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 1025 additions and 51 deletions

View file

@ -22,6 +22,7 @@ import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexe
import { generateId } from '../../../../lib/utils.js';
import {
AMBIGUOUS_POSITION,
exactPositionKey,
localNameKey,
positionKey,
qualifiedKey,
@ -168,14 +169,6 @@ function pickCallerCallableDef(
* resolution working for languages that don't yet synthesize
* qualifiers).
*/
/**
* Extract the 1-based declaration line from a scope-resolution def id.
* Shape: `def:<filePath>#<line>:<col>:<...>`; `undefined` when it doesn't match.
*/
function defStartLine(nodeId: string | undefined, filePath: string): number | undefined {
return definitionIdPosition(nodeId, filePath)?.line;
}
/**
* Trailing segment of a dotted qualified name (`Outer.inner` -> `inner`),
* with any function-local `@line:col` identity suffix stripped
@ -256,9 +249,34 @@ export function resolveDefGraphId(
// AST nodes (outer wrapper vs inner callable), but the graph node's
// `startLine` follows the initializer (#2735) so this join matches even
// when the binding is split across lines.
const line = defStartLine(def.nodeId, filePath);
const definitionPosition = definitionIdPosition(def.nodeId, filePath);
const line = definitionPosition?.line;
if (line !== undefined && isPositionQualifiedLocalLabel(def.type)) {
const simple = simpleNameOf(qn);
if (definitionPosition !== undefined) {
const exactHit = nodeLookup.get(
exactPositionKey(
filePath,
def.type,
definitionPosition.line - 1,
definitionPosition.column,
),
);
if (exactHit !== undefined && exactHit !== AMBIGUOUS_POSITION) return exactHit;
if (exactHit === undefined && siblingLabel !== undefined) {
const siblingExactHit = nodeLookup.get(
exactPositionKey(
filePath,
siblingLabel,
definitionPosition.line - 1,
definitionPosition.column,
),
);
if (siblingExactHit !== undefined && siblingExactHit !== AMBIGUOUS_POSITION) {
return siblingExactHit;
}
}
}
const posHit = nodeLookup.get(positionKey(filePath, def.type, line - 1, simple));
if (posHit !== undefined && posHit !== AMBIGUOUS_POSITION) return posHit;
// Retry under the sibling callable label when the def's OWN label

View file

@ -96,6 +96,16 @@ export function positionKey(
return `<p>:${filePath}::${label}::${startLine}::${name}`;
}
/** Exact source-position key used before the legacy line/name join. */
export function exactPositionKey(
filePath: string,
label: NodeLabel,
startLine: number,
startColumn: number,
): string {
return `<pc>:${filePath}::${label}::${startLine}:${startColumn}`;
}
/**
* Key recording that a FUNCTION-LOCAL callable with this simple name exists in the
* file (#2699 follow-up).
@ -131,6 +141,7 @@ export function buildGraphNodeLookup(graph: KnowledgeGraph): GraphNodeLookup {
name?: string;
qualifiedName?: string;
templateArguments?: readonly string[];
startColumn?: number;
};
if (props.filePath === undefined || props.name === undefined) continue;
if (!isLinkableLabel(node.label)) continue;
@ -139,6 +150,10 @@ export function buildGraphNodeLookup(graph: KnowledgeGraph): GraphNodeLookup {
// ambiguous rather than letting source order decide.
const startLine = (props as { startLine?: number }).startLine;
if (startLine !== undefined && isPositionQualifiedLocalLabel(node.label)) {
if (props.startColumn !== undefined) {
const exactK = exactPositionKey(props.filePath, node.label, startLine, props.startColumn);
lookup.set(exactK, lookup.has(exactK) ? AMBIGUOUS_POSITION : node.id);
}
const posK = positionKey(props.filePath, node.label, startLine, props.name);
lookup.set(posK, lookup.has(posK) ? AMBIGUOUS_POSITION : node.id);
// A local-identity node carries `@<row>:<col>` on its last name segment. Record

View file

@ -1128,6 +1128,34 @@ export interface ObjectLiteralBindingInfo {
ownerName?: string;
}
/**
* True when an object-literal member is contained by an array before reaching
* another callable or class boundary.
*
* An array does not provide a stable named owner for its elements, so members
* below one cannot use `<binding>.<member>` identity or ownership. They still
* need distinct graph identities, however; callers use this predicate to opt
* into source-position qualification while keeping ownership suppressed.
*/
export const isArrayContainedObjectLiteralMember = (node: SyntaxNode): boolean => {
let current: SyntaxNode | null = node;
let sawObject = false;
while (current) {
if (current.type === 'object') sawObject = true;
if (current.type === 'array' && sawObject) return true;
if (
current !== node &&
(FUNCTION_NODE_TYPES.has(current.type) || CLASS_CONTAINER_TYPES.has(current.type))
) {
return false;
}
current = current.parent;
}
return false;
};
/**
* Block-statement AST types that disqualify an object-literal binding from
* carrying a HAS_METHOD edge. A `const` declared inside one of these is block-
@ -1283,6 +1311,13 @@ export const findObjectLiteralBindingInfo = (
objectDepth += 1;
}
if (current !== node && current.type === 'array') {
// `const handlers = [{ run() {} }]` has no `handlers.run` member.
// Crossing the array would mint a confident but false owner edge; keep
// the existing conservative under-approximation used for nested objects.
return null;
}
if (current.type === 'variable_declarator' && objectDepth >= 1) {
if (objectDepth > 1) {
// Method belongs to a nested object literal; safe under-approximation.

View file

@ -43,12 +43,12 @@ function containsPosition(node: SyntaxNode, row: number, column: number): boolea
}
/**
* Zero-based start row that keys the graph-to-scope position join for a bound
* callable (#2735).
* Zero-based start position that keys the graph-to-scope join for a bound
* callable (#2735/#3041).
*
* Graph-node queries may anchor on an outer binding wrapper while the scope
* channel anchors on the inner callable. The join is line-only, so a multi-line
* binding needs the graph node's `startLine` to follow the semantic definition.
* channel anchors on the inner callable. A bound graph node therefore follows
* the semantic definition's line and column rather than the outer wrapper.
*
* `ParsedFile.localDefs` is the language-agnostic source of that position.
* Matching uses only the canonical label, name, and source range; shared worker
@ -58,21 +58,26 @@ function containsPosition(node: SyntaxNode, row: number, column: number): boolea
* Missing or ambiguous semantic matches retain the wrapper row, preserving the
* existing fail-closed behavior.
*/
export function boundCallableStartRow(
export function boundCallableStartPosition(
definitionNode: SyntaxNode,
nodeName: string,
nodeLabel: NodeLabel,
localDefs: readonly SymbolDefinition[] | undefined,
nameNode?: SyntaxNode | null,
): number {
if (localDefs === undefined) return definitionNode.startPosition.row;
): { readonly row: number; readonly column: number } {
if (localDefs === undefined) return definitionNode.startPosition;
const origin = nameNode?.startPosition ?? definitionNode.startPosition;
let best: { row: number; distance: number } | undefined;
let best: { row: number; column: number; distance: number } | undefined;
let tied = false;
for (const def of localDefs) {
if (def.type !== nodeLabel || simpleDefinitionName(def) !== nodeName) continue;
if (
def.type !== nodeLabel ||
(simpleDefinitionName(def) !== nodeName && def.qualifiedName !== nodeName)
) {
continue;
}
const position = definitionIdPosition(def.nodeId, def.filePath);
if (position === undefined) continue;
@ -82,14 +87,29 @@ export function boundCallableStartRow(
const distance =
Math.abs(row - origin.row) * 1_000_000 + Math.abs(position.column - origin.column);
if (best === undefined || distance < best.distance) {
best = { row, distance };
best = { row, column: position.column, distance };
tied = false;
} else if (distance === best.distance && row !== best.row) {
} else if (
distance === best.distance &&
(row !== best.row || position.column !== best.column)
) {
tied = true;
}
}
return best !== undefined && !tied ? best.row : definitionNode.startPosition.row;
return best !== undefined && !tied
? { row: best.row, column: best.column }
: definitionNode.startPosition;
}
export function boundCallableStartRow(
definitionNode: SyntaxNode,
nodeName: string,
nodeLabel: NodeLabel,
localDefs: readonly SymbolDefinition[] | undefined,
nameNode?: SyntaxNode | null,
): number {
return boundCallableStartPosition(definitionNode, nodeName, nodeLabel, localDefs, nameNode).row;
}
/**
* A function-local callable's own name segment: its name plus its declaration
@ -114,8 +134,13 @@ export function boundCallableStartRow(
* bare/class-qualified ids, which is what keeps this off the symbols other
* files, saved queries and stored references actually address.
*/
export const positionQualifiedCallableName = (
name: string,
position: { readonly row: number; readonly column: number },
): string => `${name}@${position.row}:${position.column}`;
export const localIdentity = (node: SyntaxNode, name: string): string =>
`${name}@${node.startPosition.row}:${node.startPosition.column}`;
positionQualifiedCallableName(name, node.startPosition);
/**
* The qualified name of a callable nested inside another callable THE single

View file

@ -1,8 +1,9 @@
import { parentPort, threadId, workerData } from 'node:worker_threads';
import {
boundCallableStartRow,
boundCallableStartPosition,
localIdentity,
nestedCallableQualifiedName,
positionQualifiedCallableName,
} from './callable-id.js';
import Parser from 'tree-sitter';
import JavaScript from 'tree-sitter-javascript';
@ -91,6 +92,7 @@ import {
getDefinitionNodeFromCaptures,
findEnclosingClassInfo,
findObjectLiteralBindingInfo,
isArrayContainedObjectLiteralMember,
findReturnShapeOwnerInfo,
isReturnShapeProperty,
findMemberAssignmentOwnerInfo,
@ -840,6 +842,13 @@ const CALLABLE_PREFIX_BOUNDARY_TYPES: ReadonlySet<string> = new Set<string>([
'anonymous_object_creation_expression', // C#
]);
/**
* Object-literal callables use the binding owner in their identity so spelling
* a member as a property or shorthand method cannot change its graph semantics.
*/
const shouldObjectOwnerQualifyCallable = (label: NodeLabel): boolean =>
label === 'Function' || label === 'Method';
const enclosingCallablePrefix = (
node: SyntaxNode,
filePath: string,
@ -907,13 +916,27 @@ const callableOwnQualifiedName = (
// `ownName === null` branch below carries the position INSTEAD of a name,
// never in addition to one, so the two spellings cannot stack.
const ownName = efnResult?.funcName ?? genericFuncName(fnNode) ?? null;
let finalLabel = efnResult?.label ?? inferFunctionLabel(fnNode.type);
if (provider.labelOverride) {
const override = provider.labelOverride(fnNode, finalLabel);
if (override !== null) finalLabel = override;
}
const prefix = enclosingCallablePrefix(fnNode, filePath, provider);
const classInfo =
prefix === undefined
? cachedFindEnclosingClassInfo(fnNode, filePath, provider.resolveEnclosingOwner)
: null;
const owner = prefix ?? classInfo?.className;
const objectOwner =
prefix === undefined && classInfo === null && shouldObjectOwnerQualifyCallable(finalLabel)
? findObjectLiteralBindingInfo(fnNode, filePath, { includeOwnerName: true })?.ownerName
: undefined;
const owner = prefix ?? classInfo?.className ?? objectOwner;
const needsArrayPosition =
owner === undefined &&
ownName !== null &&
shouldObjectOwnerQualifyCallable(finalLabel) &&
isArrayContainedObjectLiteralMember(fnNode);
const result =
prefix !== undefined
? nestedCallableQualifiedName(prefix, fnNode, ownName ?? 'fn')
@ -921,7 +944,9 @@ const callableOwnQualifiedName = (
? localIdentity(fnNode, 'fn')
: owner
? `${owner}.${ownName}`
: ownName;
: needsArrayPosition
? positionQualifiedCallableName(ownName, fnNode.startPosition)
: ownName;
callableQualifiedNameCache.set(fnNode, result);
return result;
};
@ -974,8 +999,21 @@ const findEnclosingFunctionId = (
// to the METHOD, not directly to the class, and a Go receiver method can
// never itself be nested inside another callable.
const nestedPrefix = enclosingCallablePrefix(current, filePath, provider);
const objectOwnerName =
nestedPrefix === undefined &&
classInfo === null &&
shouldObjectOwnerQualifyCallable(finalLabel)
? findObjectLiteralBindingInfo(current, filePath, { includeOwnerName: true })?.ownerName
: undefined;
const ownerName =
nestedPrefix ?? classInfo?.className ?? standaloneMethodInfo?.receiverType ?? undefined;
nestedPrefix ??
classInfo?.className ??
standaloneMethodInfo?.receiverType ??
objectOwnerName;
const needsArrayPosition =
ownerName === undefined &&
shouldObjectOwnerQualifyCallable(finalLabel) &&
isArrayContainedObjectLiteralMember(current);
// Lockstep with the other two id-building phases — see
// `nestedCallableQualifiedName`, which is the shared rule. When a
// nested prefix exists it IS `ownerName`, so this branch and the
@ -985,7 +1023,9 @@ const findEnclosingFunctionId = (
? nestedCallableQualifiedName(nestedPrefix, current, funcName)
: ownerName
? `${ownerName}.${funcName}`
: funcName;
: needsArrayPosition
? positionQualifiedCallableName(funcName, current.startPosition)
: funcName;
// Include #<arity> suffix to match definition-phase Method/Constructor IDs.
// Use the same MethodExtractor (getMethodInfo) as the definition phase.
// When same-arity collisions exist, also append ~type1,type2.
@ -2283,23 +2323,24 @@ const processFileGroup = (
// wrapper while scope-resolution anchors on the INNER expression. The
// position join is line-only, so `startLine` must follow the initializer
// (ids still use `definitionNode` via `localIdentity`).
const startRow =
const startPosition =
definitionNode &&
(nodeLabel === 'Function' || nodeLabel === 'Method' || nodeLabel === 'Constructor')
? boundCallableStartRow(
? boundCallableStartPosition(
definitionNode,
nodeName,
nodeLabel,
parsedFile?.localDefs,
nameNode,
)
: definitionNode?.startPosition.row;
: definitionNode?.startPosition;
const startLine =
startRow !== undefined
? startRow + lineOffset
startPosition !== undefined
? startPosition.row + lineOffset
: nameNode
? nameNode.startPosition.row + lineOffset
: lineOffset;
const startColumn = startPosition?.column ?? nameNode?.startPosition.column ?? 0;
// Compute enclosing class BEFORE node ID — needed to qualify method IDs
const needsOwner =
@ -2385,20 +2426,31 @@ const processFileGroup = (
// and COLLAPSE INTO ONE node — two distinct settings become one symbol,
// and the merged name then looks workspace-unique to name inference,
// which resolves reads of it to a node representing both.
const objectLiteralBindingInfo =
!enclosingClassId &&
(nodeLabel === 'Function' || nodeLabel === 'Method' || nodeLabel === 'Property') &&
definitionNode
? findObjectLiteralBindingInfo(definitionNode, file.path, {
includeOwnerName:
shouldObjectOwnerQualifyCallable(nodeLabel) || nodeLabel === 'Property',
})
: null;
const objectLiteralOwnerInfo =
!enclosingClassId && (nodeLabel === 'Method' || nodeLabel === 'Property') && definitionNode
!enclosingClassId &&
(nodeLabel === 'Function' || nodeLabel === 'Method' || nodeLabel === 'Property') &&
definitionNode
? (findMemberAssignmentOwnerInfo(definitionNode, file.path) ??
findObjectLiteralBindingInfo(definitionNode, file.path, {
// Only `Property` opts into the qualifier; `Method` ids must stay
// byte-identical or every object-literal method in every indexed
// repo changes id.
includeOwnerName: nodeLabel === 'Property',
}) ??
objectLiteralBindingInfo ??
// R3-4: an anonymous literal in return position is owned by the
// function whose shape it is. Last in the chain so a variable-bound
// literal keeps its existing owner and its existing id.
(nodeLabel === 'Property' ? findReturnShapeOwnerInfo(definitionNode, file.path) : null))
: null;
const isArrayContainedObjectCallable =
!enclosingClassId &&
shouldObjectOwnerQualifyCallable(nodeLabel) &&
definitionNode !== undefined &&
isArrayContainedObjectLiteralMember(definitionNode);
// Provenance for narrowing (R3-4). A return shape is a real definition but
// the weaker one, and the unique-name pass ranks declared anchors above it
// so indexing these cannot change an answer that already resolved.
@ -2496,7 +2548,9 @@ const processFileGroup = (
// define `bar` stay distinct nodes.
objectLiteralOwnerInfo?.ownerName !== undefined
? `${objectLiteralOwnerInfo.ownerName}.${nodeName}`
: nodeName;
: isArrayContainedObjectCallable
? positionQualifiedCallableName(nodeName, startPosition)
: nodeName;
// #2742: qualify by the enclosing `mod` chain, so two same-named items at
// different module depths in one file are DISTINCT nodes. Without this,
@ -2854,6 +2908,10 @@ const processFileGroup = (
name: nodeName,
filePath: file.path,
startLine,
...(shouldObjectOwnerQualifyCallable(nodeLabel) &&
(objectLiteralBindingInfo?.ownerName || isArrayContainedObjectCallable)
? { startColumn }
: {}),
endLine: definitionNode ? definitionNode.endPosition.row + lineOffset : startLine,
language: language,
isExported,
@ -2918,8 +2976,12 @@ const processFileGroup = (
: {}),
});
// Only emit File -> Symbol DEFINES for top-level symbols (issue #1944).
if (ownerId === undefined) {
// Object-literal callables remain file definitions as well as members of
// their exported binding. Class members still use HAS_METHOD alone.
const isTopLevelObjectCallable =
objectLiteralBindingInfo?.ownerName !== undefined &&
shouldObjectOwnerQualifyCallable(nodeLabel);
if (ownerId === undefined || isTopLevelObjectCallable) {
const fileId = generateId('File', file.path);
const relId = generateId('DEFINES', `${fileId}->${nodeId}`);
result.relationships.push({
@ -2942,7 +3004,7 @@ const processFileGroup = (
type: memberEdgeType,
confidence: 1.0,
reason: objectLiteralOwnerInfo
? 'object literal method belongs to exported object binding'
? 'object literal member belongs to exported object binding'
: '',
});
}

View file

@ -6373,6 +6373,7 @@ export class LocalBackend {
summaryOnly: true,
skipEpistemic: true,
skipEnrichment: true,
hasExplicitRelationTypes,
},
);
} catch (e) {
@ -6627,6 +6628,7 @@ export class LocalBackend {
limit: Number.isFinite(params.limit) ? params.limit : 100,
offset: Number.isFinite(params.offset) ? params.offset : 0,
pdgBridge,
hasExplicitRelationTypes,
});
return composeUnifiedPdgImpactResult(pdgResult, interproceduralResult);
} catch (e) {
@ -6643,6 +6645,7 @@ export class LocalBackend {
limit: Number.isFinite(params.limit) ? params.limit : 100,
offset: Number.isFinite(params.offset) ? params.offset : 0,
summaryOnly: params.summaryOnly,
hasExplicitRelationTypes,
});
}
@ -7016,6 +7019,8 @@ export class LocalBackend {
skipEpistemic?: boolean;
skipEnrichment?: boolean;
pdgBridge?: PdgBridgeOptions;
/** Preserve an explicit caller filter; implicit structural seeds must not widen it. */
hasExplicitRelationTypes?: boolean;
},
): Promise<any> {
const { maxDepth, relationTypes, includeTests, minConfidence } = opts;
@ -7078,7 +7083,11 @@ export class LocalBackend {
const visited = new Set<string>([symId]);
const pdgBridgeEvidenceById = new Map<string, PdgBridgeEvidenceInfo>();
let frontier = [symId];
const objectCallableFrontier: string[] = [];
let traversalComplete = true;
// Fetch one sentinel row beyond the cap so generated object bindings
// degrade visibly instead of allocating an unbounded seed frontier.
const OBJECT_CALLABLE_MEMBER_CAP = 5000;
// Fix #480: For Java (and other JVM) Class/Interface nodes, CALLS edges
// point to Constructor nodes and IMPORTS edges point to File nodes — not
@ -7153,6 +7162,63 @@ export class LocalBackend {
}
} catch (e) {
logQueryError('impact:class-node-expansion', e);
traversalComplete = false;
}
}
// Function-valued properties on exported object bindings are represented
// as Const/Variable -[:HAS_METHOD]-> Function. HAS_METHOD is intentionally
// absent from the default usage traversal, but downstream impact on the
// binding still needs to enter its own callable member before following
// CALLS.
if (
direction === 'downstream' &&
(symType === 'Const' || symType === 'Variable') &&
relationTypes.includes('CALLS') &&
!relationTypes.includes('HAS_METHOD') &&
!opts.hasExplicitRelationTypes
) {
try {
const memberRows = await executeParameterized(
repo.lbugPath,
`
MATCH (n)-[hm:CodeRelation]->(member:Function)
WHERE n.id = $symId AND hm.type = 'HAS_METHOD'
RETURN DISTINCT member.id AS id, member.name AS name,
'Function' AS type, member.filePath AS filePath
ORDER BY id
LIMIT ${OBJECT_CALLABLE_MEMBER_CAP + 1}
UNION ALL
MATCH (n)-[hm:CodeRelation]->(member:Method)
WHERE n.id = $symId AND hm.type = 'HAS_METHOD'
RETURN DISTINCT member.id AS id, member.name AS name,
'Method' AS type, member.filePath AS filePath
ORDER BY id
LIMIT ${OBJECT_CALLABLE_MEMBER_CAP + 1}
`,
{ symId },
);
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)) {
const memberId = row.id || row[0];
if (memberId && !visited.has(memberId)) {
visited.add(memberId);
objectCallableFrontier.push(memberId);
impacted.push({
depth: 1,
id: memberId,
name: row.name || row[1],
type: row.type || row[2],
filePath: row.filePath || row[3] || '',
relationType: 'HAS_METHOD',
confidence: 1,
});
}
}
} catch (e) {
logQueryError('impact:object-callable-expansion', e);
traversalComplete = false;
}
}
@ -7307,7 +7373,10 @@ export class LocalBackend {
break;
}
frontier = nextFrontier;
frontier =
depth === 1 && objectCallableFrontier.length > 0
? [...new Set([...nextFrontier, ...objectCallableFrontier])]
: nextFrontier;
}
// Stamp the finalized, order-independent bridge evidence (strongest across
@ -7921,6 +7990,7 @@ export class LocalBackend {
// the #1858 epistemic/boundaries fields — computing them per neighbor is
// dead work on the highest-volume path, so suppress them here too.
skipEpistemic: true,
hasExplicitRelationTypes: opts.relationTypes.length > 0,
});
} catch {
return null;

View file

@ -568,7 +568,6 @@ import type { ParseWorkerResult } from '../core/ingestion/workers/parse-worker.j
// and the v37/v38 clash it was written for: the next free value above every
// IN-FLIGHT claim, not above origin/main. Every open PR touching gitnexus/ was
// scanned; #3017 is the only other claimant.
// RE-CHECK AGAINST origin/main AND OPEN PRs IMMEDIATELY BEFORE MERGING.
//
// 72 -> 74 adds import-proven Convex endpoint metadata to Const/Function worker
// output. A warm v72 cache has no convexEndpointFactory property, so the MCP
@ -578,7 +577,17 @@ import type { ParseWorkerResult } from '../core/ingestion/workers/parse-worker.j
// migration needs both guarantees. Version 73 is intentionally skipped because
// concurrent PR #3046 (fixes #3041) claims it. Re-check main and open PRs
// immediately before merge.
const SCHEMA_BUMP = 74;
//
// 74 -> 76 makes object-literal Function and Method members owner-qualified
// (#3041). A warm v74 cache replays the old collapsed callable ids and omits
// the new Const/Variable -> callable HAS_METHOD ownership edges, so this bump
// makes unchanged files re-parse rather than waiting for a source edit. The
// persisted graph is rebuilt separately when `analyzerRunnerIdentitiesEqual`
// detects the changed analyzer build in run-analyze.ts. Both guards are
// required; a parse-cache bump alone must never be read as a graph rebuild.
// Version 75 is intentionally skipped because concurrent PR #3017 claims it.
// RE-CHECK AGAINST origin/main AND OPEN PRs IMMEDIATELY BEFORE MERGING.
const SCHEMA_BUMP = 76;
const GITNEXUS_PKG_VERSION = (() => {
try {
// package.json sits at gitnexus/package.json — two levels up from

View file

@ -7,6 +7,7 @@
* - happy path: file-scope export const / const / export var returns binding
* - local-inside-function / arrow / class-constructor null
* - nested object literal null (safe under-approximation)
* - object literal reached through an array null
* - block-scoped declaration (if / for body) null
* - IIFE-wrapped object literal null
* - assignment without declarator null (no throw)
@ -125,6 +126,17 @@ describe('findObjectLiteralBindingInfo — negative: nested literals', () => {
});
});
describe('findObjectLiteralBindingInfo — negative: array elements', () => {
it('does not invent an owner member for an object literal inside an array', () => {
const tree = parseTs(`export const handlers = [{ run() {} }, { run() {} }];`);
const methodNodes = findMethodNodes(tree.rootNode, 'run');
expect(methodNodes).toHaveLength(2);
for (const methodNode of methodNodes) {
expect(findObjectLiteralBindingInfo(methodNode, 'src/handlers.ts')).toBe(null);
}
});
});
describe('findObjectLiteralBindingInfo — negative: block scope', () => {
it('declared inside top-level if-block → null', () => {
const tree = parseTs(`

View file

@ -0,0 +1,185 @@
import { beforeAll, describe, expect, it, vi } from 'vitest';
import { LocalBackend } from '../../src/mcp/local/local-backend.js';
import { listRegisteredRepos } from '../../src/storage/repo-manager.js';
import { withTestLbugDB, type IndexedDBHandle } from '../helpers/test-indexed-db.js';
vi.mock('../../src/storage/repo-manager.js', async (importActual) => ({
...(await importActual<typeof import('../../src/storage/repo-manager.js')>()),
listRegisteredRepos: vi.fn().mockResolvedValue([]),
cleanupOldKuzuFiles: vi.fn().mockResolvedValue({ found: false, needsReindex: false }),
findSiblingClones: vi.fn().mockResolvedValue([]),
}));
type BackendHandle = IndexedDBHandle & { backend?: LocalBackend };
const FIRST = 'Const:src/convex.ts:first';
const SECOND = 'Const:src/convex.ts:second';
const THIRD = 'Variable:src/convex.ts:third';
const FIRST_HANDLER = 'Function:src/convex.ts:first.handler';
const SECOND_HANDLER = 'Function:src/convex.ts:second.handler';
const THIRD_HANDLER = 'Function:src/convex.ts:third.handler';
const HELPER_A = 'Function:src/convex.ts:helperA';
const HELPER_B = 'Function:src/convex.ts:helperB';
const HELPER_C = 'Function:src/convex.ts:helperC';
const SERVICE = 'Const:src/convex.ts:service';
const SERVICE_RUN = 'Method:src/convex.ts:service.run#0';
const HELPER_D = 'Function:src/convex.ts:helperD';
withTestLbugDB(
'object-literal-impact-3041',
(handle) => {
describe('downstream impact through object-owned callables (#3041)', () => {
let backend: LocalBackend;
beforeAll(() => {
const attached = (handle as BackendHandle).backend;
if (!attached) throw new Error('LocalBackend was not attached during setup');
backend = attached;
});
it.each([
['first', HELPER_A, HELPER_B],
['second', HELPER_B, HELPER_A],
])('%s reaches only its own helper', async (target, expected, excluded) => {
const result = await backend.callTool('impact', {
target,
direction: 'downstream',
});
expect(result).not.toHaveProperty('error');
const ids = Object.values(result.byDepth ?? {})
.flatMap((entries) => entries as Array<{ id: string }>)
.map((entry) => entry.id);
expect(ids).toContain(expected);
expect(ids).not.toContain(excluded);
});
it('a let binding reaches its owned handler calls', async () => {
const result = await backend.callTool('impact', {
target: 'third',
direction: 'downstream',
});
expect(result).not.toHaveProperty('error');
const ids = Object.values(result.byDepth ?? {})
.flatMap((entries) => entries as Array<{ id: string }>)
.map((entry) => entry.id);
expect(ids).toContain(HELPER_C);
expect(ids).not.toContain(HELPER_A);
expect(ids).not.toContain(HELPER_B);
});
it('counts the implicit member as depth 1 and its callee as depth 2', async () => {
const firstHop = await backend.callTool('impact', {
target: 'first',
direction: 'downstream',
maxDepth: 1,
});
expect(firstHop.byDepth?.['1']?.map((entry: { id: string }) => entry.id)).toEqual([
FIRST_HANDLER,
]);
expect(firstHop.byDepth?.['2']).toBeUndefined();
const secondHop = await backend.callTool('impact', {
target: 'first',
direction: 'downstream',
maxDepth: 2,
});
expect(secondHop.byDepth?.['1']?.map((entry: { id: string }) => entry.id)).toEqual([
FIRST_HANDLER,
]);
expect(secondHop.byDepth?.['2']?.map((entry: { id: string }) => entry.id)).toContain(
HELPER_A,
);
expect(secondHop.summary?.direct).toBe(1);
});
it('does not widen an explicit CALLS-only traversal through HAS_METHOD', async () => {
const result = await backend.callTool('impact', {
target: 'first',
direction: 'downstream',
relationTypes: ['CALLS'],
maxDepth: 3,
});
expect(result.impactedCount).toBe(0);
expect(result.byDepth).toEqual({});
});
it('enters a Method-labelled shorthand member on the default traversal', async () => {
const result = await backend.callTool('impact', {
target: 'service',
direction: 'downstream',
maxDepth: 2,
});
expect(result.byDepth?.['1']?.map((entry: { id: string }) => entry.id)).toEqual([
SERVICE_RUN,
]);
expect(result.byDepth?.['2']?.map((entry: { id: string }) => entry.id)).toEqual([HELPER_D]);
});
it('preserves explicit HAS_METHOD traversal depth', async () => {
const firstHop = await backend.callTool('impact', {
target: 'first',
direction: 'downstream',
maxDepth: 1,
relationTypes: ['HAS_METHOD', 'CALLS'],
});
expect(firstHop).not.toHaveProperty('error');
expect(firstHop.byDepth?.['1']?.map((entry: { id: string }) => entry.id)).toEqual([
FIRST_HANDLER,
]);
const secondHop = await backend.callTool('impact', {
target: 'first',
direction: 'downstream',
maxDepth: 2,
relationTypes: ['HAS_METHOD', 'CALLS'],
});
expect(secondHop).not.toHaveProperty('error');
expect(secondHop.byDepth?.['2']?.map((entry: { id: string }) => entry.id)).toContain(
HELPER_A,
);
});
});
},
{
seed: [
`CREATE (:Const {id: '${FIRST}', name: 'first', filePath: 'src/convex.ts', startLine: 1, endLine: 1})`,
`CREATE (:Const {id: '${SECOND}', name: 'second', filePath: 'src/convex.ts', startLine: 2, endLine: 2})`,
`CREATE (:Variable {id: '${THIRD}', name: 'third', filePath: 'src/convex.ts', startLine: 3, endLine: 3})`,
`CREATE (:Function {id: '${FIRST_HANDLER}', name: 'handler', filePath: 'src/convex.ts', startLine: 3, endLine: 3})`,
`CREATE (:Function {id: '${SECOND_HANDLER}', name: 'handler', filePath: 'src/convex.ts', startLine: 4, endLine: 4})`,
`CREATE (:Function {id: '${THIRD_HANDLER}', name: 'handler', filePath: 'src/convex.ts', startLine: 5, endLine: 5})`,
`CREATE (:Function {id: '${HELPER_A}', name: 'helperA', filePath: 'src/convex.ts', startLine: 5, endLine: 5})`,
`CREATE (:Function {id: '${HELPER_B}', name: 'helperB', filePath: 'src/convex.ts', startLine: 6, endLine: 6})`,
`CREATE (:Function {id: '${HELPER_C}', name: 'helperC', filePath: 'src/convex.ts', startLine: 7, endLine: 7})`,
`CREATE (:Const {id: '${SERVICE}', name: 'service', filePath: 'src/convex.ts', startLine: 8, endLine: 8})`,
`CREATE (:Method {id: '${SERVICE_RUN}', name: 'run', filePath: 'src/convex.ts', startLine: 8, endLine: 8})`,
`CREATE (:Function {id: '${HELPER_D}', name: 'helperD', filePath: 'src/convex.ts', startLine: 9, endLine: 9})`,
`MATCH (a:Const), (b:Function) WHERE a.id = '${FIRST}' AND b.id = '${FIRST_HANDLER}' CREATE (a)-[:CodeRelation {type: 'HAS_METHOD', confidence: 1.0, reason: 'object literal member'}]->(b)`,
`MATCH (a:Const), (b:Function) WHERE a.id = '${SECOND}' AND b.id = '${SECOND_HANDLER}' CREATE (a)-[:CodeRelation {type: 'HAS_METHOD', confidence: 1.0, reason: 'object literal member'}]->(b)`,
`MATCH (a:Variable), (b:Function) WHERE a.id = '${THIRD}' AND b.id = '${THIRD_HANDLER}' CREATE (a)-[:CodeRelation {type: 'HAS_METHOD', confidence: 1.0, reason: 'object literal member'}]->(b)`,
`MATCH (a:Function), (b:Function) WHERE a.id = '${FIRST_HANDLER}' AND b.id = '${HELPER_A}' CREATE (a)-[:CodeRelation {type: 'CALLS', confidence: 0.85, reason: 'direct'}]->(b)`,
`MATCH (a:Function), (b:Function) WHERE a.id = '${SECOND_HANDLER}' AND b.id = '${HELPER_B}' CREATE (a)-[:CodeRelation {type: 'CALLS', confidence: 0.85, reason: 'direct'}]->(b)`,
`MATCH (a:Function), (b:Function) WHERE a.id = '${THIRD_HANDLER}' AND b.id = '${HELPER_C}' CREATE (a)-[:CodeRelation {type: 'CALLS', confidence: 0.85, reason: 'direct'}]->(b)`,
`MATCH (a:Const), (b:Method) WHERE a.id = '${SERVICE}' AND b.id = '${SERVICE_RUN}' CREATE (a)-[:CodeRelation {type: 'HAS_METHOD', confidence: 1.0, reason: 'object literal member'}]->(b)`,
`MATCH (a:Method), (b:Function) WHERE a.id = '${SERVICE_RUN}' AND b.id = '${HELPER_D}' CREATE (a)-[:CodeRelation {type: 'CALLS', confidence: 0.85, reason: 'direct'}]->(b)`,
],
poolAdapter: true,
afterSetup: async (handle) => {
vi.mocked(listRegisteredRepos).mockResolvedValue([
{
name: 'object-literal-impact-repo',
path: '/object-literal-impact/repo',
storagePath: handle.tmpHandle.dbPath,
indexedAt: new Date().toISOString(),
lastCommit: 'abc123',
stats: { files: 1, nodes: 6, communities: 0, processes: 0 },
},
]);
const backend = new LocalBackend();
await backend.init();
(handle as BackendHandle).backend = backend;
},
},
);

View file

@ -36,6 +36,17 @@ import {
type PipelineResult,
} from './resolvers/helpers.js';
import { generateId } from '../../src/lib/utils.js';
import {
loadParseCache,
PARSE_CACHE_VERSION,
pruneCache,
saveParseCache,
type ParseCache,
} from '../../src/storage/parse-cache.js';
import {
getDurableParsedFileDir,
pruneAndSaveDurableParsedFileStore,
} from '../../src/storage/parsedfile-store.js';
const DIST_WORKER = path.resolve(
__dirname,
@ -146,7 +157,7 @@ describe.skipIf(!hasDistWorker)('object-literal owner resolution — worker pipe
// The Method node id encodes arity disambiguation (#1 = one-arity overload).
// Pin the canonical id so a regression that targets a phantom node fails.
const expectedTargetId = generateId('Method', 'src/service.ts:getUser#1');
const expectedTargetId = generateId('Method', 'src/service.ts:fooService.getUser#1');
expect(callerToGetUser).toEqual([
{
targetId: expectedTargetId,
@ -236,3 +247,331 @@ describe.skipIf(!hasDistWorker)(
});
},
);
// ── #3041: same-named function-valued properties ───────────────────────────
describe.skipIf(!hasDistWorker)(
'object-literal owner resolution — same-named property callables (#3041)',
() => {
let repoRoot: string;
let result: PipelineResult;
beforeAll(async () => {
repoRoot = writeFixture({
'src/convex.ts': `function query<T>(config: T): T { return config; }
function helperA(ctx: unknown) { return ctx; }
function helperB(ctx: unknown) { return ctx; }
function helperC(ctx: unknown) { return ctx; }
export const first = query({ handler: async (ctx: unknown) => helperA(ctx) }); export const second = query({ handler: async (ctx: unknown) => helperB(ctx) });
export let third = query({ handler: async (ctx: unknown) => helperC(ctx) });
`,
});
result = await runPipelineFromRepo(repoRoot, () => undefined, {
skipGraphPhases: true,
});
}, 60000);
afterAll(() => removeFixture(repoRoot));
it('creates one owner-qualified handler node per exported const', () => {
const handlerIds: string[] = [];
result.graph.forEachNode((node) => {
if (node.label === 'Function' && node.properties.name === 'handler') {
handlerIds.push(node.id);
}
});
expect(handlerIds.sort()).toEqual(
[
generateId('Function', 'src/convex.ts:first.handler'),
generateId('Function', 'src/convex.ts:second.handler'),
generateId('Function', 'src/convex.ts:third.handler'),
].sort(),
);
});
it('links each exported const to only its own handler', () => {
const ownership = getRelationships(result, 'HAS_METHOD')
.filter((edge) => edge.target === 'handler')
.map((edge) => `${edge.source}:${edge.rel.targetId}`)
.sort();
expect(ownership).toEqual(
[
`first:${generateId('Function', 'src/convex.ts:first.handler')}`,
`second:${generateId('Function', 'src/convex.ts:second.handler')}`,
`third:${generateId('Function', 'src/convex.ts:third.handler')}`,
].sort(),
);
});
it('keeps each handler call on its real owner with no cross-attribution', () => {
const calls = getRelationships(result, 'CALLS')
.filter(
(edge) =>
edge.target === 'helperA' || edge.target === 'helperB' || edge.target === 'helperC',
)
.map((edge) => `${edge.rel.sourceId}->${edge.target}`)
.sort();
expect(calls).toEqual(
[
`${generateId('Function', 'src/convex.ts:first.handler')}->helperA`,
`${generateId('Function', 'src/convex.ts:second.handler')}->helperB`,
`${generateId('Function', 'src/convex.ts:third.handler')}->helperC`,
].sort(),
);
});
it('keeps owner-qualified handlers reachable from their file definition', () => {
const defined = getRelationships(result, 'DEFINES')
.filter((edge) => edge.target === 'handler')
.map((edge) => edge.rel.targetId)
.sort();
expect(defined).toEqual(
[
generateId('Function', 'src/convex.ts:first.handler'),
generateId('Function', 'src/convex.ts:second.handler'),
generateId('Function', 'src/convex.ts:third.handler'),
].sort(),
);
});
},
);
describe.skipIf(!hasDistWorker)('object-literal shorthand method identity (#3041)', () => {
let repoRoot: string;
let result: PipelineResult;
beforeAll(async () => {
repoRoot = writeFixture({
'src/shorthand.ts': `function helperA(value: string) { return value; }
function helperB(value: string) { return value; }
export const alpha = { run(value: string) { return helperA(value); } };
export const beta = { run(value: string) { return helperB(value); } };
`,
});
result = await runPipelineFromRepo(repoRoot, () => undefined, {
skipGraphPhases: true,
});
}, 60_000);
afterAll(() => removeFixture(repoRoot));
it('gives sibling shorthand methods distinct owner-qualified identities and calls', () => {
const nodeIds = new Set<string>();
result.graph.forEachNode((node) => nodeIds.add(node.id));
const alphaRun = generateId('Method', 'src/shorthand.ts:alpha.run#1');
const betaRun = generateId('Method', 'src/shorthand.ts:beta.run#1');
const calls = getRelationships(result, 'CALLS')
.filter((edge) => edge.target === 'helperA' || edge.target === 'helperB')
.map((edge) => `${edge.rel.sourceId}->${edge.target}`)
.sort();
expect(nodeIds.has(alphaRun)).toBe(true);
expect(nodeIds.has(betaRun)).toBe(true);
expect(calls).toEqual([`${alphaRun}->helperA`, `${betaRun}->helperB`]);
});
it('keeps shorthand methods on both File DEFINES and binding HAS_METHOD edges', () => {
const methodIds = [
generateId('Method', 'src/shorthand.ts:alpha.run#1'),
generateId('Method', 'src/shorthand.ts:beta.run#1'),
].sort();
const defined = getRelationships(result, 'DEFINES')
.filter((edge) => edge.target === 'run')
.map((edge) => edge.rel.targetId)
.sort();
const owned = getRelationships(result, 'HAS_METHOD')
.filter((edge) => edge.target === 'run')
.map((edge) => edge.rel.targetId)
.sort();
expect(defined).toEqual(methodIds);
expect(owned).toEqual(methodIds);
});
});
describe.skipIf(!hasDistWorker)('object-literal dotted property identity (#3041)', () => {
let repoRoot: string;
let result: PipelineResult;
beforeAll(async () => {
repoRoot = writeFixture({
'src/dotted.ts': `function helperC(value: number) { return value; }
function helperD(value: number) { return value; }
export const p = { 'q.r': (value: number) => helperC(value) };
export const z = { r: (value: number) => helperD(value) };
`,
});
result = await runPipelineFromRepo(repoRoot, () => undefined, {
skipGraphPhases: true,
});
}, 60_000);
afterAll(() => removeFixture(repoRoot));
it('attributes dotted and plain member calls to their own owner-qualified nodes', () => {
const calls = getRelationships(result, 'CALLS')
.filter((edge) => edge.target === 'helperC' || edge.target === 'helperD')
.map((edge) => `${edge.rel.sourceId}->${edge.target}`)
.sort();
expect(calls).toEqual(
[
`${generateId('Function', 'src/dotted.ts:p.q.r')}->helperC`,
`${generateId('Function', 'src/dotted.ts:z.r')}->helperD`,
].sort(),
);
});
});
describe.skipIf(!hasDistWorker)('object-literal array ownership barrier (#3041)', () => {
let repoRoot: string;
let result: PipelineResult;
beforeAll(async () => {
repoRoot = writeFixture({
'src/array.ts': `function helperA(value: number) { return value; }
function helperB(value: number) { return value; }
export const handlers = [
{ handle: (value: number) => helperA(value) },
{ handle: (value: number) => helperB(value) },
];
`,
});
result = await runPipelineFromRepo(repoRoot, () => undefined, {
skipGraphPhases: true,
});
}, 60_000);
afterAll(() => removeFixture(repoRoot));
it('keeps array-contained callables distinct without inventing ownership', () => {
const falseId = generateId('Function', 'src/array.ts:handlers.handle');
const bareId = generateId('Function', 'src/array.ts:handle');
const handlerIds = [
generateId('Function', 'src/array.ts:handle@3:12'),
generateId('Function', 'src/array.ts:handle@4:12'),
].sort();
const nodeIds = new Set<string>();
result.graph.forEachNode((node) => nodeIds.add(node.id));
const calls = getRelationships(result, 'CALLS')
.filter((edge) => edge.target === 'helperA' || edge.target === 'helperB')
.map((edge) => `${edge.rel.sourceId}->${edge.target}`)
.sort();
const falseOwnership = getRelationships(result, 'HAS_METHOD').filter(
(edge) => edge.source === 'handlers' && edge.target === 'handle',
);
expect(nodeIds.has(falseId)).toBe(false);
expect(nodeIds.has(bareId)).toBe(false);
expect(handlerIds.every((id) => nodeIds.has(id))).toBe(true);
expect(calls).toEqual([`${handlerIds[0]}->helperA`, `${handlerIds[1]}->helperB`].sort());
expect(falseOwnership).toEqual([]);
});
});
describe.skipIf(!hasDistWorker)('nested array object Method identity (#3041)', () => {
let repoRoot: string;
let result: PipelineResult;
beforeAll(async () => {
repoRoot = writeFixture({
'src/nested-array.ts': `function helperA(value: number) { return value; }
function helperB(value: number) { return value; }
export const registry = {
groups: [[
{ handle(value: number) { return helperA(value); } },
{ handle(value: number) { return helperB(value); } },
]],
};
`,
});
result = await runPipelineFromRepo(repoRoot, () => undefined, {
skipGraphPhases: true,
});
}, 60_000);
afterAll(() => removeFixture(repoRoot));
it('position-qualifies shorthand methods through nested arrays and objects', () => {
const expectedIds = [
generateId('Method', 'src/nested-array.ts:handle@4:6#1'),
generateId('Method', 'src/nested-array.ts:handle@5:6#1'),
].sort();
const nodeIds = new Set<string>();
result.graph.forEachNode((node) => nodeIds.add(node.id));
const calls = getRelationships(result, 'CALLS')
.filter((edge) => edge.target === 'helperA' || edge.target === 'helperB')
.map((edge) => `${edge.rel.sourceId}->${edge.target}`)
.sort();
const ownership = getRelationships(result, 'HAS_METHOD').filter(
(edge) => edge.target === 'handle',
);
expect(expectedIds.every((id) => nodeIds.has(id))).toBe(true);
expect(calls).toEqual([`${expectedIds[0]}->helperA`, `${expectedIds[1]}->helperB`].sort());
expect(ownership).toEqual([]);
});
});
describe.skipIf(!hasDistWorker)('object-literal callable durable cache (#3041)', () => {
it('replays owner-qualified handler identities and calls without workers', async () => {
const repoRoot = writeFixture({
'src/convex.ts': `function query<T>(config: T): T { return config; }
function helperA(ctx: unknown) { return ctx; }
function helperB(ctx: unknown) { return ctx; }
export const first = query({ handler: (ctx: unknown) => helperA(ctx) });
export const second = query({ handler: (ctx: unknown) => helperB(ctx) });
`,
});
const storage = fs.mkdtempSync(path.join(os.tmpdir(), 'gnx-objlit-cache-'));
try {
const coldCache: ParseCache = {
version: PARSE_CACHE_VERSION,
entries: new Map(),
usedKeys: new Set(),
storagePath: storage,
onDiskKeys: new Set(),
};
const cold = await runPipelineFromRepo(repoRoot, () => undefined, {
skipGraphPhases: true,
parseCache: coldCache,
workerPoolSize: 1,
});
pruneCache(coldCache, coldCache.usedKeys);
const savedKeys = await saveParseCache(storage, coldCache);
await pruneAndSaveDurableParsedFileStore(
getDurableParsedFileDir(storage),
PARSE_CACHE_VERSION,
new Set(savedKeys),
);
const warmCache = await loadParseCache(storage);
const warm = await runPipelineFromRepo(repoRoot, () => undefined, {
skipGraphPhases: true,
parseCache: warmCache ?? undefined,
workerPoolSize: 1,
});
const project = (pipeline: PipelineResult) =>
getRelationships(pipeline, 'CALLS')
.filter((edge) => edge.target === 'helperA' || edge.target === 'helperB')
.map((edge) => `${edge.rel.sourceId}->${edge.target}`)
.sort();
expect(warm.usedWorkerPool).toBe(false);
expect(project(warm)).toEqual(project(cold));
expect(project(warm)).toEqual(
[
`${generateId('Function', 'src/convex.ts:first.handler')}->helperA`,
`${generateId('Function', 'src/convex.ts:second.handler')}->helperB`,
].sort(),
);
} finally {
removeFixture(repoRoot);
fs.rmSync(storage, { recursive: true, force: true });
}
}, 120_000);
});

View file

@ -128,7 +128,7 @@ describe('incremental reuse gate — schema fingerprint (U-C5, #2798)', () => {
});
});
describe('semantic (non-DDL) analyzer changes ride the runner-identity receipt (#2798)', () => {
describe('semantic and id-shape changes ride the runner-identity receipt (#2798/#3041)', () => {
it('run-analyze.ts still forces a full rebuild when the stamped runner identity differs', () => {
// The invariant the INCREMENTAL_SCHEMA_VERSION ladder used to backstop. It
// is implicit nowhere else: no other gate observes analyzer code that emits

View file

@ -324,4 +324,139 @@ describe('impact: batching and grouping', () => {
// Cleanup env
delete process.env.IMPACT_MAX_CHUNKS;
});
it('caps implicit object-callable expansion and reports partial impact', async () => {
const backend = new LocalBackend();
const repoHandle = {
id: 'repo-object-cap',
name: 'repo-object-cap',
repoPath: '/tmp/repo-object-cap',
storagePath: '/tmp/repo-object-cap/.gitnexus',
lbugPath: '/tmp/repo-object-cap/.gitnexus/lbug',
indexedAt: 'now',
lastCommit: 'c',
stats: {},
} as any;
executeParameterizedMock.mockImplementation(async (...args: any[]) => {
const query = String(args[1] ?? '');
if (
query.includes("hm.type = 'HAS_METHOD'") &&
query.includes('member:Function') &&
query.includes('member:Method')
) {
return Array.from({ length: 5001 }, (_, i) => ({
id: `member-${i}`,
name: `member${i}`,
type: i % 2 === 0 ? 'Function' : 'Method',
filePath: 'src/object.ts',
}));
}
return [];
});
const result = await (backend as any)._runImpactBFS(
repoHandle,
{ id: 'owner', name: 'owner' },
'Const',
'downstream',
{
maxDepth: 1,
relationTypes: ['CALLS'],
includeTests: false,
minConfidence: 0,
skipEpistemic: true,
skipEnrichment: true,
},
);
const memberCall = executeParameterizedMock.mock.calls.find((args: any[]) =>
String(args[1] ?? '').includes('member:Function'),
);
const traversalCall = executeParameterizedMock.mock.calls.find((args: any[]) =>
String(args[1] ?? '').includes('r.type IN $relTypes'),
);
expect(String(memberCall?.[1])).toContain('RETURN DISTINCT member.id AS id');
expect(String(memberCall?.[1])).toContain('member:Method');
expect(String(memberCall?.[1])).toContain('UNION ALL');
expect(String(memberCall?.[1])).toContain('ORDER BY id');
expect(String(memberCall?.[1])).toContain('LIMIT 5001');
expect(traversalCall?.[2]?.frontierIds).toEqual(['owner']);
expect(result.byDepth['1']).toHaveLength(5000);
expect(result.partial).toBe(true);
});
it('marks object impact partial when callable seeding fails', async () => {
const backend = new LocalBackend();
const repoHandle = {
id: 'repo-object-seed-failure',
name: 'repo-object-seed-failure',
repoPath: '/tmp/repo-object-seed-failure',
storagePath: '/tmp/repo-object-seed-failure/.gitnexus',
lbugPath: '/tmp/repo-object-seed-failure/.gitnexus/lbug',
indexedAt: 'now',
lastCommit: 'c',
stats: {},
} as any;
executeParameterizedMock.mockImplementation(async (...args: any[]) => {
const query = String(args[1] ?? '');
if (query.includes('member:Function')) throw new Error('seed unavailable');
return [];
});
const result = await (backend as any)._runImpactBFS(
repoHandle,
{ id: 'owner', name: 'owner' },
'Const',
'downstream',
{
maxDepth: 1,
relationTypes: ['CALLS'],
includeTests: false,
minConfidence: 0,
skipEpistemic: true,
skipEnrichment: true,
},
);
expect(result.partial).toBe(true);
});
it('marks class impact partial when structural seeding fails', async () => {
const backend = new LocalBackend();
const repoHandle = {
id: 'repo-class-seed-failure',
name: 'repo-class-seed-failure',
repoPath: '/tmp/repo-class-seed-failure',
storagePath: '/tmp/repo-class-seed-failure/.gitnexus',
lbugPath: '/tmp/repo-class-seed-failure/.gitnexus/lbug',
indexedAt: 'now',
lastCommit: 'c',
stats: {},
} as any;
executeParameterizedMock.mockImplementation(async (...args: any[]) => {
const query = String(args[1] ?? '');
if (query.includes('(c:Constructor)')) throw new Error('seed unavailable');
return [];
});
const result = await (backend as any)._runImpactBFS(
repoHandle,
{ id: 'class-owner', name: 'Owner' },
'Class',
'downstream',
{
maxDepth: 1,
relationTypes: ['CALLS'],
includeTests: false,
minConfidence: 0,
skipEpistemic: true,
skipEnrichment: true,
},
);
expect(result.partial).toBe(true);
});
});

View file

@ -230,14 +230,14 @@ describe('PARSE_CACHE_VERSION', () => {
// replayed pre-feature captures and the feature was inert. 71 is the next
// free value above every claim at this merge — origin/main is 70 and open
// PR #3017 already claims 71, so 71 would have collided.
it('pins SCHEMA_BUMP to 74 so concurrent bumps cannot silently collide (#2766)', () => {
expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(74);
it('pins SCHEMA_BUMP to 76 so v74 caches cannot retain pre-#3041 identities', () => {
expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(76);
// The PREVIOUS version must fail the reuse gate, not merely differ from the
// current one — a hardcoded number outside the conflict hunk rebases cleanly
// while being wrong, which is exactly how the 37/38 exact clashes landed.
// Every nearby historical or in-flight value is rejected, including 69,
// which carried the route-table payload before this merge.
for (const taken of [59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73]) {
for (const taken of [59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75]) {
expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).not.toBe(taken);
}
});

View file

@ -20,6 +20,7 @@ interface Candidate {
name?: string;
qualifiedName?: string;
startLine?: number;
startColumn?: number;
}
function buildLookup(candidates: readonly Candidate[]) {
@ -34,6 +35,7 @@ function buildLookup(candidates: readonly Candidate[]) {
qualifiedName: candidate.qualifiedName ?? 'Service.save',
filePath: FILE,
...(candidate.startLine !== undefined ? { startLine: candidate.startLine } : {}),
...(candidate.startColumn !== undefined ? { startColumn: candidate.startColumn } : {}),
},
}) satisfies ParseWorkerResult['nodes'][number],
);
@ -96,6 +98,73 @@ describe('parse-result graph insertion determinism', () => {
expect(lookup.get(simpleKey(FILE, 'save'))).toBe(first.id);
});
it('uses exact columns to distinguish same-line owner-qualified callables', () => {
const first = {
id: `Function:${FILE}:first.handler`,
label: 'Function' as const,
name: 'handler',
qualifiedName: 'first.handler',
startLine: 4,
startColumn: 24,
};
const second = {
id: `Function:${FILE}:second.handler`,
label: 'Function' as const,
name: 'handler',
qualifiedName: 'second.handler',
startLine: 4,
startColumn: 73,
};
const lookup = buildLookup([second, first]);
expect(
resolveDefGraphId(
FILE,
{
nodeId: `def:${FILE}#5:24:Function:handler`,
type: 'Function',
qualifiedName: 'handler',
},
lookup,
),
).toBe(first.id);
expect(
resolveDefGraphId(
FILE,
{
nodeId: `def:${FILE}#5:73:Function:handler`,
type: 'Function',
qualifiedName: 'handler',
},
lookup,
),
).toBe(second.id);
});
it('uses exact position before parsing dotted member names as qualifiers', () => {
const dotted = {
id: `Function:${FILE}:service.q.r`,
label: 'Function' as const,
name: 'q.r',
qualifiedName: 'service.q.r',
startLine: 8,
startColumn: 31,
};
const lookup = buildLookup([dotted]);
expect(
resolveDefGraphId(
FILE,
{
nodeId: `def:${FILE}#9:31:Function:q.r`,
type: 'Function',
qualifiedName: 'q.r',
},
lookup,
),
).toBe(dotted.id);
});
it('resolves a Record definition to its Record node instead of a same-named fallback', () => {
const record = {
id: `Record:${FILE}:Person`,