diff --git a/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/ids.ts b/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/ids.ts index c0289d496..d0f99de15 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/ids.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/ids.ts @@ -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:#::<...>`; `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 diff --git a/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/node-lookup.ts b/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/node-lookup.ts index 5099508e3..2658b1fd7 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/node-lookup.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/node-lookup.ts @@ -96,6 +96,16 @@ export function positionKey( return `

:${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 `:${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 `@:` on its last name segment. Record diff --git a/gitnexus/src/core/ingestion/utils/ast-helpers.ts b/gitnexus/src/core/ingestion/utils/ast-helpers.ts index 65f06bd31..1a88c9a15 100644 --- a/gitnexus/src/core/ingestion/utils/ast-helpers.ts +++ b/gitnexus/src/core/ingestion/utils/ast-helpers.ts @@ -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 `.` 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. diff --git a/gitnexus/src/core/ingestion/workers/callable-id.ts b/gitnexus/src/core/ingestion/workers/callable-id.ts index cb8cea942..7672dd8c8 100644 --- a/gitnexus/src/core/ingestion/workers/callable-id.ts +++ b/gitnexus/src/core/ingestion/workers/callable-id.ts @@ -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 diff --git a/gitnexus/src/core/ingestion/workers/parse-worker.ts b/gitnexus/src/core/ingestion/workers/parse-worker.ts index 1d1f3dab4..946d060a6 100644 --- a/gitnexus/src/core/ingestion/workers/parse-worker.ts +++ b/gitnexus/src/core/ingestion/workers/parse-worker.ts @@ -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 = new Set([ '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 # 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' : '', }); } diff --git a/gitnexus/src/mcp/local/local-backend.ts b/gitnexus/src/mcp/local/local-backend.ts index 9809a76e4..1679a3b80 100644 --- a/gitnexus/src/mcp/local/local-backend.ts +++ b/gitnexus/src/mcp/local/local-backend.ts @@ -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 { const { maxDepth, relationTypes, includeTests, minConfidence } = opts; @@ -7078,7 +7083,11 @@ export class LocalBackend { const visited = new Set([symId]); const pdgBridgeEvidenceById = new Map(); 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; diff --git a/gitnexus/src/storage/parse-cache.ts b/gitnexus/src/storage/parse-cache.ts index 903a57016..64ddc8264 100644 --- a/gitnexus/src/storage/parse-cache.ts +++ b/gitnexus/src/storage/parse-cache.ts @@ -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 diff --git a/gitnexus/test/integration/ast-helpers-object-literal-binding.test.ts b/gitnexus/test/integration/ast-helpers-object-literal-binding.test.ts index 99f3700aa..9cbb75bda 100644 --- a/gitnexus/test/integration/ast-helpers-object-literal-binding.test.ts +++ b/gitnexus/test/integration/ast-helpers-object-literal-binding.test.ts @@ -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(` diff --git a/gitnexus/test/integration/object-literal-impact.test.ts b/gitnexus/test/integration/object-literal-impact.test.ts new file mode 100644 index 000000000..6e69b56fa --- /dev/null +++ b/gitnexus/test/integration/object-literal-impact.test.ts @@ -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()), + 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; + }, + }, +); diff --git a/gitnexus/test/integration/object-literal-owner-resolution.test.ts b/gitnexus/test/integration/object-literal-owner-resolution.test.ts index 176e6e19a..e2513711b 100644 --- a/gitnexus/test/integration/object-literal-owner-resolution.test.ts +++ b/gitnexus/test/integration/object-literal-owner-resolution.test.ts @@ -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(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(); + 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(); + 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(); + 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(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); +}); diff --git a/gitnexus/test/unit/call-summary-schema-version.test.ts b/gitnexus/test/unit/call-summary-schema-version.test.ts index da6648858..e92e5db77 100644 --- a/gitnexus/test/unit/call-summary-schema-version.test.ts +++ b/gitnexus/test/unit/call-summary-schema-version.test.ts @@ -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 diff --git a/gitnexus/test/unit/impact-batching-grouping.test.ts b/gitnexus/test/unit/impact-batching-grouping.test.ts index e600042de..8224cd1d3 100644 --- a/gitnexus/test/unit/impact-batching-grouping.test.ts +++ b/gitnexus/test/unit/impact-batching-grouping.test.ts @@ -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); + }); }); diff --git a/gitnexus/test/unit/incremental-parse-cache.test.ts b/gitnexus/test/unit/incremental-parse-cache.test.ts index 57a76437b..89b840feb 100644 --- a/gitnexus/test/unit/incremental-parse-cache.test.ts +++ b/gitnexus/test/unit/incremental-parse-cache.test.ts @@ -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); } }); diff --git a/gitnexus/test/unit/scope-resolution/node-lookup-determinism.test.ts b/gitnexus/test/unit/scope-resolution/node-lookup-determinism.test.ts index 031de0870..bb5900cd3 100644 --- a/gitnexus/test/unit/scope-resolution/node-lookup-determinism.test.ts +++ b/gitnexus/test/unit/scope-resolution/node-lookup-determinism.test.ts @@ -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`,