mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-08-28 05:25:25 +00:00
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
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:
parent
09322d2d89
commit
ac68f5254c
14 changed files with 1025 additions and 51 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
: '',
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(`
|
||||
|
|
|
|||
185
gitnexus/test/integration/object-literal-impact.test.ts
Normal file
185
gitnexus/test/integration/object-literal-impact.test.ts
Normal 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;
|
||||
},
|
||||
},
|
||||
);
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue