Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
9a27e6c
fix: link object literal methods to exported bindings
luyua9 May 20, 2026
8721f61
Merge branch 'main' into fix/ts-object-method-exports-luyua9
magyargergo May 20, 2026
bbc9568
Merge branch 'main' into fix/ts-object-method-exports-luyua9
magyargergo May 20, 2026
bafeaa6
Merge branch 'main' into fix/ts-object-method-exports-luyua9
magyargergo May 20, 2026
3960648
Merge branch 'main' into fix/ts-object-method-exports-luyua9
magyargergo May 21, 2026
e9531ce
fix(ingestion): bridge object-literal value receivers in scope-resolu…
magyargergo May 21, 2026
8931f60
refactor(ingestion): address code-review findings on object-literal o…
magyargergo May 21, 2026
b3a4e42
Merge branch 'main' into fix/ts-object-method-exports-luyua9
magyargergo May 21, 2026
5794d98
refactor(scope-resolution): align Const label emission with legacy DA…
magyargergo May 21, 2026
9cf5f37
Merge branch 'main' into fix/ts-object-method-exports-luyua9
magyargergo May 21, 2026
0df91b7
test(ingestion): add regression coverage for issue #1358 singleton su…
magyargergo May 21, 2026
e897732
Merge branch 'main' into fix/ts-object-method-exports-luyua9
magyargergo May 21, 2026
c8e573b
test(resolvers): add class-instance + factory-pattern singleton cover…
magyargergo May 21, 2026
088e7e8
Merge branch 'main' into fix/ts-object-method-exports-luyua9
magyargergo May 21, 2026
b3365bc
Merge branch 'main' into fix/ts-object-method-exports-luyua9
magyargergo May 21, 2026
d6645a4
test(resolvers): gate TS/JS singleton tests behind scope-resolution p…
magyargergo May 21, 2026
150856d
Merge branch 'main' into fix/ts-object-method-exports-luyua9
magyargergo May 21, 2026
594d3e9
Merge branch 'main' into fix/ts-object-method-exports-luyua9
magyargergo May 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 13 additions & 5 deletions gitnexus/src/core/ingestion/parsing-processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { isVerboseIngestionEnabled } from './utils/verbose.js';
import {
getDefinitionNodeFromCaptures,
findEnclosingClassInfo,
findObjectLiteralBindingInfo,
getLabelFromCaptures,
CLASS_CONTAINER_TYPES,
type SyntaxNode,
Expand Down Expand Up @@ -531,6 +532,10 @@ const processParsingSequential = async (
)
: null;
const enclosingClassId = enclosingClassInfo?.classId ?? null;
const objectLiteralOwnerInfo =
!enclosingClassId && nodeLabel === 'Method' && definitionNode
? findObjectLiteralBindingInfo(definitionNode, file.path)
: null;

// Qualify method/property IDs with enclosing class name to avoid collisions
// e.g. "Method:animal.dart:Animal.speak" vs "Method:animal.dart:Dog.speak"
Expand Down Expand Up @@ -785,7 +790,7 @@ const processParsingSequential = async (
returnType: methodProps.returnType as string | undefined,
declaredType,
templateArguments: classTemplateArguments,
ownerId: enclosingClassId ?? undefined,
ownerId: enclosingClassId ?? objectLiteralOwnerInfo?.ownerId ?? undefined,
qualifiedName: qualifiedTypeName,
});

Expand All @@ -805,15 +810,18 @@ const processParsingSequential = async (
graph.addRelationship(relationship);

// ── HAS_METHOD / HAS_PROPERTY: link member to enclosing class ──
if (enclosingClassId) {
const ownerIdForMemberEdge = enclosingClassId ?? objectLiteralOwnerInfo?.ownerId ?? null;
if (ownerIdForMemberEdge) {
const memberEdgeType = nodeLabel === 'Property' ? 'HAS_PROPERTY' : 'HAS_METHOD';
graph.addRelationship({
id: generateId(memberEdgeType, `${enclosingClassId}->${nodeId}`),
sourceId: enclosingClassId,
id: generateId(memberEdgeType, `${ownerIdForMemberEdge}->${nodeId}`),
sourceId: ownerIdForMemberEdge,
targetId: nodeId,
type: memberEdgeType,
confidence: 1.0,
reason: '',
reason: objectLiteralOwnerInfo
? 'object literal method belongs to exported object binding'
: '',
});
}
});
Expand Down
8 changes: 7 additions & 1 deletion gitnexus/src/core/ingestion/scope-extractor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -693,8 +693,14 @@ function normalizeNodeLabel(kindStr: string): SymbolDefinition['type'] | undefin
case 'property':
return 'Property';
case 'variable':
case 'const':
return 'Variable';
// `const` / `let` declarations align with the legacy DAG parse phase,
// which emits `Const` graph nodes via `@definition.const` capture for
// `lexical_declaration`. Returning `'Const'` here lets resolveDefGraphId's
// qualified-key path succeed for value receivers without relying on the
// simple-key fallback (PR #1718 review Finding 1 / 2026-05-21-002 U4).
case 'const':
return 'Const';
case 'typealias':
case 'type_alias':
return 'TypeAlias';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -98,3 +98,54 @@ export function tryEmitEdge(
});
return true;
}

/**
* Variant of `tryEmitEdge` that takes a pre-resolved target graph id
* instead of resolving it from a `SymbolDefinition`. Used by the
* value-receiver-owner bridge (`receiver-bound-calls.ts` Case 5) where
* the picked owner-indexed method def carries no `qualifiedName` (object
* literals have no class owner to seed it) and therefore cannot
* round-trip through `resolveDefGraphId`. The def's `nodeId` IS the
* canonical graph node id (written by the parse phase), so the caller
* passes it directly.
*
* All other invariants of `tryEmitEdge` apply: dedup key shape, collapse
* flag honoring, edge-type mapping, caller-id resolution.
*/
export function tryEmitEdgeWithExplicitTargetId(
graph: KnowledgeGraph,
scopes: ScopeResolutionIndexes,
nodeLookup: GraphNodeLookup,
site: {
readonly inScope: ScopeId;
readonly atRange: { startLine: number; startCol: number };
readonly kind: string;
},
targetGraphId: string,
reason: string,
seen: Set<string>,
confidence = 0.85,
collapseByCallerTarget = false,
): boolean {
const callerGraphId = resolveCallerGraphId(site.inScope, scopes, nodeLookup);
const edgeType = mapReferenceKindToEdgeType(site.kind as Reference['kind']);
if (callerGraphId === undefined) return false;
if (edgeType === undefined) return false;

const useCollapsed = collapseByCallerTarget && edgeType === 'CALLS';
const dedupKey = useCollapsed
? `${edgeType}:${callerGraphId}->${targetGraphId}`
: `${edgeType}:${callerGraphId}->${targetGraphId}:${site.atRange.startLine}:${site.atRange.startCol}`;
if (seen.has(dedupKey)) return false;
seen.add(dedupKey);

graph.addRelationship({
id: `rel:${dedupKey}`,
sourceId: callerGraphId,
targetId: targetGraphId,
type: edgeType,
confidence,
reason,
});
return true;
}
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,12 @@ export function isLinkableLabel(label: NodeLabel): boolean {
// ACCESSES edges target field nodes (e.g. `user.name = "x"` →
// ACCESSES edge to User's `name` Variable/Property node).
label === 'Variable' ||
label === 'Property'
label === 'Property' ||
// Const is linkable so the value-receiver-owner bridge in
// `receiver-bound-calls.ts` Case 5 can translate the scope-resolution
// `Variable` def for `export const fooService = {...}` to the canonical
// `Const:filePath:name` graph node id, against which object-literal
// method symbols register their `ownerId` (PR #1718 / issue #1358).
label === 'Const'
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@
* but not a namespace prefix → compound resolver
* 7. **Case 4 (simple typeBinding)** — `typeRef.rawName` has no dot →
* MRO walk + `findOwnedMember`
* 8. **Case 5 (value-receiver bridge)** — receiver is a `Const`/`Variable`
* whose `nodeId` is referenced as an `ownerId` in `model.methods`
* (object-literal services). Last-resort fallback for lowercase
* receivers with no class-like or type-binding match. Mirrors
* the legacy DAG bridge in `call-processor.ts`.
*
* Reordering or merging cases changes resolution semantics.
*
Expand All @@ -46,9 +51,10 @@ import {
findExportedDef,
findOwnedMember,
findReceiverTypeBinding,
findValueBindingInScope,
isClassLike,
} from '../scope/walkers.js';
import { tryEmitEdge } from '../graph-bridge/edges.js';
import { tryEmitEdge, tryEmitEdgeWithExplicitTargetId } from '../graph-bridge/edges.js';
import { resolveCompoundReceiverClass } from '../passes/compound-receiver.js';
import { resolveDefGraphId } from '../graph-bridge/ids.js';
import {
Expand Down Expand Up @@ -706,6 +712,61 @@ export function emitReceiverBoundCalls(
}
}
}

// ── Case 5: value-receiver bridge (object-literal services) ──
// When prior cases couldn't resolve the receiver as a class or
// type binding, fall back to value-binding resolution. Covers:
//
// export const fooService = { getUser(id) {...} };
// import { fooService } from './service';
// fooService.getUser(id); // ← resolve here
//
// `fooService` is a `Const`/`Variable` (not class-like, no typeBinding
// for unannotated literals), so Cases 2-4 skip it. Scope-resolution
// defs for non-class values carry a synthetic id, so we translate to
// the canonical graph node ID via `resolveDefGraphId` before owner-
// indexed lookup — the parser writes the graph node ID as `ownerId`
// on the method symbol-table entry to match.
//
// Object-literal methods do not carry a `qualifiedName` (no class
// owner to seed it), so the picked def cannot round-trip through
// `tryEmitEdge` → `resolveDefGraphId`. We use
// `tryEmitEdgeWithExplicitTargetId` instead, passing `picked.nodeId`
// directly — same dedup-key shape, collapse-flag honoring, and
// caller resolution as `tryEmitEdge`.
const valueDef = findValueBindingInScope(site.inScope, receiverName, scopes);
if (valueDef !== undefined) {
const ownerGraphId =
resolveDefGraphId(valueDef.filePath, valueDef, nodeLookup) ?? valueDef.nodeId;
const picked = pickOverload(ownerGraphId, memberName, site, model, provider);
if (picked === OVERLOAD_AMBIGUOUS) {
handledSites.add(siteKey);
continue;
}
if (picked !== undefined) {
const reason =
site.kind === 'write' || site.kind === 'read'
? site.kind
: picked.filePath !== parsed.filePath
? 'import-resolved'
: 'global';
const confidence = site.kind === 'write' || site.kind === 'read' ? 1.0 : 0.85;
const ok = tryEmitEdgeWithExplicitTargetId(
graph,
scopes,
nodeLookup,
site,
picked.nodeId,
reason,
seen,
confidence,
collapse,
);
if (ok) emitted++;
handledSites.add(siteKey);
continue;
}
}
}
}

Expand Down
106 changes: 85 additions & 21 deletions gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,28 +165,9 @@ export function findClassBindingInScope(
receiverName: string,
scopes: ScopeResolutionIndexes,
): SymbolDefinition | undefined {
let currentId: ScopeId | null = startScope;
const visited = new Set<ScopeId>();
while (currentId !== null) {
if (visited.has(currentId)) return undefined;
visited.add(currentId);
const scope = scopes.scopeTree.getScope(currentId);
if (scope === undefined) return undefined;

const localBindings = scope.bindings.get(receiverName);
if (localBindings !== undefined) {
for (const b of localBindings) {
if (isClassLike(b.def.type)) return b.def;
}
}
const local = walkScopeChain(startScope, receiverName, scopes, (def) => isClassLike(def.type));
if (local !== undefined) return local;

const importedBindings = lookupBindingsAt(currentId, receiverName, scopes);
for (const b of importedBindings) {
if (isClassLike(b.def.type)) return b.def;
}

currentId = scope.parent;
}
// Fallback for languages (Go) where namespace-style imports don't
// create scope bindings: resolve via QualifiedNameIndex. Only fires
// when the scope-chain walk found nothing; single-match wins.
Expand All @@ -211,6 +192,89 @@ export function findClassBindingInScope(
return undefined;
}

/**
* Predicate for value-receiver bridge: the labels for which
* `reconcileOwnership` registers methods/fields under the def's
* `nodeId` as the `ownerId`. Explicit allowlist so future NodeLabel
* additions (Module, Namespace, TypeAlias, EnumMember, etc.) do NOT
* silently widen the bridge — adding a new ownerable label requires
* touching both this predicate and `reconcileOwnership`.
*
* See: `scope-resolution/pipeline/reconcile-ownership.ts` Property /
* Variable / Const / Static registration block.
*/
export function isOwnableValueLabel(t: string): boolean {
return t === 'Const' || t === 'Variable' || t === 'Property' || t === 'Static';
}

/**
* Look up a value-binding (Const/Variable/Property/Static) by name in
* the given scope's chain. Used by the value-receiver-owner bridge
* for object-literal services such as:
*
* export const fooService = { getUser(id) {...} };
*
* where `fooService` is a `Const`/`Variable` whose `nodeId` is the
* `ownerId` of the member method. Neither `findClassBindingInScope`
* (rejects non-class-like) nor `findReceiverTypeBinding` (no typeBinding
* for an unannotated literal) finds it.
*
* Mirrors `findClassBindingInScope` exactly; only the accepted def-type
* predicate differs.
*/
export function findValueBindingInScope(
startScope: ScopeId,
receiverName: string,
scopes: ScopeResolutionIndexes,
): SymbolDefinition | undefined {
return walkScopeChain(startScope, receiverName, scopes, (def) => isOwnableValueLabel(def.type));
}

/**
* Generic scope-chain walker. Walks from `startScope` toward the root,
* consulting both the local `scope.bindings` channel and the dual-source
* `lookupBindingsAt` view (finalized + augmented). At each scope, local
* bindings are exhausted BEFORE imported/augmented bindings — preserves
* JavaScript-style lexical scoping where a local `const x` shadows an
* imported `x` of the same name.
*
* Returns the first binding `def` matching `predicate`. Cycles in the
* scope graph terminate the walk (defensive — should not occur in
* well-formed inputs).
*/
function walkScopeChain(
startScope: ScopeId,
name: string,
scopes: ScopeResolutionIndexes,
predicate: (def: SymbolDefinition) => boolean,
): SymbolDefinition | undefined {
let currentId: ScopeId | null = startScope;
const visited = new Set<ScopeId>();
while (currentId !== null) {
if (visited.has(currentId)) return undefined;
visited.add(currentId);
const scope = scopes.scopeTree.getScope(currentId);
if (scope === undefined) return undefined;

// Local first: a `const x` in this scope shadows any imported `x`.
const localBindings = scope.bindings.get(name);
if (localBindings !== undefined) {
for (const b of localBindings) {
if (predicate(b.def)) return b.def;
}
}

// Then imported/augmented bindings — only consulted when no local match.
const importedBindings = lookupBindingsAt(currentId, name, scopes);
for (const b of importedBindings) {
if (predicate(b.def)) return b.def;
}

currentId = scope.parent;
}
return undefined;
}

/**
* Look up a callable (Function/Method/Constructor) by name in the
* given scope's chain. Uses the dual-source pattern (scope.bindings +
Expand Down
Loading
Loading