mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-05 02:43:32 +00:00
refactor(emit-core): promote receiver-bound dispatcher + compound resolver
G-Unit 3 of the emit-pipeline generalization plan. - emit-core/emit-compound-receiver.ts — resolveCompoundReceiverClass + matchingOpenParen + COMPOUND_RECEIVER_MAX_DEPTH. Field-fallback is now an option (default true) so strictly-typed languages can opt out via EmitProvider.fieldFallbackOnMethodLookup. - emit-core/emit-receiver-bound.ts — the 7-case dispatcher (super, Cases 0/1/2/3/3b/4). Accepts a ReceiverBoundProviderSubset (isSuperReceiver + fieldFallbackOnMethodLookup) so partial wiring works during the rest of the migration. Documents Contract Invariants I4 (case order) and I5 (no pre-seeding). python-scope-emit.ts shrinks 799 → 384 lines. The orchestrator now calls the generic emitReceiverBoundCalls with an inline minimal provider (pythonEmitProviderInline) — full provider lands in G-Unit 6 when the orchestrator itself moves to languages/python/emit/. Verification: - REGISTRY_PRIMARY_PYTHON=0 (legacy): 191/191. - REGISTRY_PRIMARY_PYTHON=1 (registry): 191/191. - tsc --noEmit clean.
This commit is contained in:
parent
791acc330e
commit
46fe4c0a39
4 changed files with 501 additions and 442 deletions
168
gitnexus/src/core/ingestion/emit-core/emit-compound-receiver.ts
Normal file
168
gitnexus/src/core/ingestion/emit-core/emit-compound-receiver.ts
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
/**
|
||||
* Resolve a compound-receiver expression's TYPE — `user.address.save()`,
|
||||
* `svc.get_user().save()`, `c.greet().save()` — to the class def of
|
||||
* the value the receiver expression produces.
|
||||
*
|
||||
* Three shapes (parsed C-family-style):
|
||||
* - bare identifier `name` — look up via typeBinding chain
|
||||
* - dotted `obj.field[.field]…` — walk fields via class-scope typeBindings
|
||||
* - call `expr.method()` — recurse into expr, find method's return-type
|
||||
* typeBinding on its class, resolve to a class
|
||||
*
|
||||
* **Field-fallback heuristic** (Phase-9C "unified fixpoint"): when the
|
||||
* receiver class has no `methodName`, walk its fields and try the
|
||||
* lookup on each field's type. Useful for dynamically-typed languages
|
||||
* (Python). Strictly-typed languages should pass
|
||||
* `fieldFallbackOnMethodLookup: false` via `EmitProvider`.
|
||||
*
|
||||
* Generic for any C-family language (`.` member access, `()` call
|
||||
* syntax). Languages with non-C-family syntax (Ruby blocks, COBOL)
|
||||
* either don't trigger the call branch or skip this pass entirely.
|
||||
*/
|
||||
|
||||
import type { ParsedFile, ScopeId, SymbolDefinition, TypeRef } from 'gitnexus-shared';
|
||||
import type { Scope } from 'gitnexus-shared';
|
||||
import type { ScopeResolutionIndexes } from '../model/scope-resolution-indexes.js';
|
||||
import {
|
||||
findClassBindingInScope,
|
||||
findExportedDefByName,
|
||||
findReceiverTypeBinding,
|
||||
} from './scope-walkers.js';
|
||||
|
||||
/** Max depth for compound-receiver chain resolution (`a().b().c().d()`).
|
||||
* Practical code rarely exceeds 3-4 hops; the cap prevents
|
||||
* pathological recursion if the receiver text is malformed. */
|
||||
const COMPOUND_RECEIVER_MAX_DEPTH = 4;
|
||||
|
||||
export interface ResolveCompoundReceiverOptions {
|
||||
/** When true (default), if method lookup fails on the receiver's
|
||||
* class, walk its fields and try the lookup on each field's class.
|
||||
* Phase-9C "unified fixpoint" — Python-shaped heuristic. */
|
||||
readonly fieldFallback?: boolean;
|
||||
}
|
||||
|
||||
export function resolveCompoundReceiverClass(
|
||||
receiverText: string,
|
||||
inScope: ScopeId,
|
||||
scopes: ScopeResolutionIndexes,
|
||||
parsedFiles: readonly ParsedFile[],
|
||||
classScopeByDefId: ReadonlyMap<string, Scope>,
|
||||
options: ResolveCompoundReceiverOptions = {},
|
||||
depth = 0,
|
||||
): SymbolDefinition | undefined {
|
||||
if (depth > COMPOUND_RECEIVER_MAX_DEPTH) return undefined;
|
||||
const text = receiverText.trim();
|
||||
if (text.length === 0) return undefined;
|
||||
const fieldFallback = options.fieldFallback ?? true;
|
||||
|
||||
// Bare identifier — resolve via typeBinding then class lookup.
|
||||
if (!text.includes('.') && !text.includes('(')) {
|
||||
const tb = findReceiverTypeBinding(inScope, text, scopes);
|
||||
if (tb === undefined) return undefined;
|
||||
return findClassBindingInScope(tb.declaredAtScope, tb.rawName, scopes);
|
||||
}
|
||||
|
||||
// Trailing `()` — call expression. Strip it and resolve the function
|
||||
// expression's return type. We only handle the canonical `f()` /
|
||||
// `obj.method()` shape; nested-arg expressions like `f(g())` are
|
||||
// out of scope for V1 (depth-capped recursion catches infinite loops).
|
||||
if (text.endsWith(')')) {
|
||||
const openIdx = matchingOpenParen(text);
|
||||
if (openIdx === -1) return undefined;
|
||||
const fnExpr = text.slice(0, openIdx).trim();
|
||||
if (fnExpr.length === 0) return undefined;
|
||||
|
||||
const lastDot = fnExpr.lastIndexOf('.');
|
||||
if (lastDot === -1) {
|
||||
// Free call `name()`. Look up function in scope, then its
|
||||
// return-type typeBinding (which lives in the function's
|
||||
// enclosing scope per the language's return-type hoist rule).
|
||||
const fnDef = findExportedDefByName(fnExpr, inScope, scopes, parsedFiles);
|
||||
if (fnDef === undefined) return undefined;
|
||||
const retType = findReceiverTypeBinding(inScope, fnExpr, scopes);
|
||||
if (retType === undefined) return undefined;
|
||||
return findClassBindingInScope(retType.declaredAtScope, retType.rawName, scopes);
|
||||
}
|
||||
|
||||
// `obj.method()` — resolve obj's class, look up method's return
|
||||
// type on that class scope (or the MRO).
|
||||
const objExpr = fnExpr.slice(0, lastDot);
|
||||
const methodName = fnExpr.slice(lastDot + 1);
|
||||
const objClass = resolveCompoundReceiverClass(
|
||||
objExpr,
|
||||
inScope,
|
||||
scopes,
|
||||
parsedFiles,
|
||||
classScopeByDefId,
|
||||
options,
|
||||
depth + 1,
|
||||
);
|
||||
if (objClass === undefined) return undefined;
|
||||
|
||||
let retType: TypeRef | undefined;
|
||||
const ownerChain = [objClass.nodeId, ...scopes.methodDispatch.mroFor(objClass.nodeId)];
|
||||
for (const ownerId of ownerChain) {
|
||||
const cs = classScopeByDefId.get(ownerId);
|
||||
const candidate = cs?.typeBindings.get(methodName);
|
||||
if (candidate !== undefined) {
|
||||
retType = candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (retType === undefined && fieldFallback) {
|
||||
const objCs = classScopeByDefId.get(objClass.nodeId);
|
||||
if (objCs !== undefined) {
|
||||
for (const [, fieldType] of objCs.typeBindings) {
|
||||
const fieldClass = findClassBindingInScope(
|
||||
fieldType.declaredAtScope,
|
||||
fieldType.rawName,
|
||||
scopes,
|
||||
);
|
||||
if (fieldClass === undefined) continue;
|
||||
const fcs = classScopeByDefId.get(fieldClass.nodeId);
|
||||
const candidate = fcs?.typeBindings.get(methodName);
|
||||
if (candidate !== undefined) {
|
||||
retType = candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (retType === undefined) return undefined;
|
||||
return findClassBindingInScope(retType.declaredAtScope, retType.rawName, scopes);
|
||||
}
|
||||
|
||||
// Pure dotted access `obj.field[.field]…` — walk fields.
|
||||
const parts = text.split('.');
|
||||
const head = parts[0]!;
|
||||
const headType = findReceiverTypeBinding(inScope, head, scopes);
|
||||
let currentClass: SymbolDefinition | undefined = headType
|
||||
? findClassBindingInScope(headType.declaredAtScope, headType.rawName, scopes)
|
||||
: undefined;
|
||||
for (let i = 1; i < parts.length && currentClass !== undefined; i++) {
|
||||
const fieldName = parts[i]!;
|
||||
const cs = classScopeByDefId.get(currentClass.nodeId);
|
||||
const fieldType = cs?.typeBindings.get(fieldName);
|
||||
if (fieldType === undefined) return undefined;
|
||||
currentClass = findClassBindingInScope(fieldType.declaredAtScope, fieldType.rawName, scopes);
|
||||
}
|
||||
return currentClass;
|
||||
}
|
||||
|
||||
/** Find the index of the `(` that matches the trailing `)` of a
|
||||
* call-expression text. Returns -1 if unbalanced. */
|
||||
export function matchingOpenParen(text: string): number {
|
||||
if (!text.endsWith(')')) return -1;
|
||||
let depth = 0;
|
||||
for (let i = text.length - 1; i >= 0; i--) {
|
||||
const ch = text[i];
|
||||
if (ch === ')') depth++;
|
||||
else if (ch === '(') {
|
||||
depth--;
|
||||
if (depth === 0) return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
310
gitnexus/src/core/ingestion/emit-core/emit-receiver-bound.ts
Normal file
310
gitnexus/src/core/ingestion/emit-core/emit-receiver-bound.ts
Normal file
|
|
@ -0,0 +1,310 @@
|
|||
/**
|
||||
* Receiver-bound CALLS / ACCESSES emit pass — generic 7-case
|
||||
* dispatcher consuming `EmitProvider` for the language-specific bits
|
||||
* (super recognizer, field-fallback toggle).
|
||||
*
|
||||
* **Contract Invariant I4 — case order is load-bearing.** The cases
|
||||
* are evaluated in this order; the FIRST that emits an edge wins:
|
||||
*
|
||||
* 1. **super branch** — `provider.isSuperReceiver(receiverName)` →
|
||||
* MRO walk skipping self
|
||||
* 2. **Case 0 (compound)** — receiver has `.` or `(` → compound resolver
|
||||
* 3. **Case 1 (namespace)** — receiver in `namespaceTargets` → exported def
|
||||
* 4. **Case 2 (class-name)** — receiver resolves to a Class binding →
|
||||
* MRO walk on that class
|
||||
* 5. **Case 3 (dotted typeBinding for namespace prefix)** —
|
||||
* `typeRef.rawName` like `models.User`
|
||||
* 6. **Case 3b (chain-typebinding)** — `typeRef.rawName` has a dot
|
||||
* but not a namespace prefix → compound resolver
|
||||
* 7. **Case 4 (simple typeBinding)** — `typeRef.rawName` has no dot →
|
||||
* MRO walk + `findOwnedMember`
|
||||
*
|
||||
* Reordering or merging cases changes resolution semantics.
|
||||
*
|
||||
* **Contract Invariant I5 — pre-seeding `seen` is forbidden.** The
|
||||
* orchestrator runs this pass FIRST (before `emitReferencesViaLookup`)
|
||||
* and consumes the populated `handledSites` set. Pre-seeding `seen`
|
||||
* from the shared resolver's emissions (an old optimization) actively
|
||||
* suppresses correct emissions for sites the shared resolver also
|
||||
* resolved to a wrong target.
|
||||
*/
|
||||
|
||||
import type { ParsedFile, SymbolDefinition } from 'gitnexus-shared';
|
||||
import type { Scope } from 'gitnexus-shared';
|
||||
import type { KnowledgeGraph } from '../../graph/types.js';
|
||||
import type { ScopeResolutionIndexes } from '../model/scope-resolution-indexes.js';
|
||||
import type { EmitProvider } from './emit-provider.js';
|
||||
import type { GraphNodeLookup } from './graph-node-lookup.js';
|
||||
import { collectNamespaceTargets } from './namespace-targets.js';
|
||||
import {
|
||||
findClassBindingInScope,
|
||||
findEnclosingClassDef,
|
||||
findExportedDef,
|
||||
findOwnedMember,
|
||||
findReceiverTypeBinding,
|
||||
} from './scope-walkers.js';
|
||||
import { tryEmitEdge } from './emit-edge.js';
|
||||
import { resolveCompoundReceiverClass } from './emit-compound-receiver.js';
|
||||
|
||||
/** Subset of `EmitProvider` consumed by this pass. Accepting the
|
||||
* subset rather than the full provider keeps tests and partial
|
||||
* refactors lighter — callers only need to populate what we read. */
|
||||
export type ReceiverBoundProviderSubset = Pick<
|
||||
EmitProvider,
|
||||
'isSuperReceiver' | 'fieldFallbackOnMethodLookup'
|
||||
>;
|
||||
|
||||
export function emitReceiverBoundCalls(
|
||||
graph: KnowledgeGraph,
|
||||
scopes: ScopeResolutionIndexes,
|
||||
parsedFiles: readonly ParsedFile[],
|
||||
nodeLookup: GraphNodeLookup,
|
||||
handledSites: Set<string>,
|
||||
provider: ReceiverBoundProviderSubset,
|
||||
): number {
|
||||
let emitted = 0;
|
||||
// Per-pass dedup so the multiple cases don't double-emit if two of
|
||||
// them resolve the same site to the same target. NEVER pre-seed
|
||||
// from the reference index — see Contract Invariant I5.
|
||||
const seen = new Set<string>();
|
||||
const fieldFallback = provider.fieldFallbackOnMethodLookup ?? true;
|
||||
|
||||
// Class def → Class scope map (for field-chain field-type lookup).
|
||||
// The class scope's `ownedDefs` contains the Class def per pass2's
|
||||
// structural-ownership rule.
|
||||
const classScopeByDefId = new Map<string, Scope>();
|
||||
for (const p of parsedFiles) {
|
||||
for (const scope of p.scopes) {
|
||||
if (scope.kind !== 'Class') continue;
|
||||
const cd = scope.ownedDefs.find((d) => d.type === 'Class');
|
||||
if (cd !== undefined) classScopeByDefId.set(cd.nodeId, scope);
|
||||
}
|
||||
}
|
||||
|
||||
for (const parsed of parsedFiles) {
|
||||
const namespaceTargets = collectNamespaceTargets(parsed, scopes);
|
||||
|
||||
for (const site of parsed.referenceSites) {
|
||||
if (site.kind !== 'call' && site.kind !== 'read' && site.kind !== 'write') continue;
|
||||
if (site.explicitReceiver === undefined) continue;
|
||||
|
||||
const receiverName = site.explicitReceiver.name;
|
||||
const memberName = site.name;
|
||||
const siteKey = `${parsed.filePath}:${site.atRange.startLine}:${site.atRange.startCol}`;
|
||||
|
||||
// ── super branch ─────────────────────────────────────────────
|
||||
if (provider.isSuperReceiver(receiverName)) {
|
||||
const enclosingClass = findEnclosingClassDef(site.inScope, scopes);
|
||||
if (enclosingClass !== undefined) {
|
||||
const ancestors = scopes.methodDispatch.mroFor(enclosingClass.nodeId);
|
||||
let memberDef: SymbolDefinition | undefined;
|
||||
for (const ownerId of ancestors) {
|
||||
memberDef = findOwnedMember(ownerId, memberName, parsedFiles);
|
||||
if (memberDef !== undefined) break;
|
||||
}
|
||||
if (memberDef !== undefined) {
|
||||
const ok = tryEmitEdge(
|
||||
graph,
|
||||
scopes,
|
||||
nodeLookup,
|
||||
site,
|
||||
memberDef,
|
||||
'scope-resolution: super-receiver',
|
||||
seen,
|
||||
);
|
||||
if (ok) {
|
||||
emitted++;
|
||||
handledSites.add(siteKey);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Case 0: compound receiver ────────────────────────────────
|
||||
if (receiverName.includes('.') || receiverName.includes('(')) {
|
||||
const currentClass = resolveCompoundReceiverClass(
|
||||
receiverName,
|
||||
site.inScope,
|
||||
scopes,
|
||||
parsedFiles,
|
||||
classScopeByDefId,
|
||||
{ fieldFallback },
|
||||
);
|
||||
if (currentClass !== undefined) {
|
||||
const chain = [currentClass.nodeId, ...scopes.methodDispatch.mroFor(currentClass.nodeId)];
|
||||
let memberDef: SymbolDefinition | undefined;
|
||||
for (const ownerId of chain) {
|
||||
memberDef = findOwnedMember(ownerId, memberName, parsedFiles);
|
||||
if (memberDef !== undefined) break;
|
||||
}
|
||||
if (memberDef !== undefined) {
|
||||
const ok = tryEmitEdge(
|
||||
graph,
|
||||
scopes,
|
||||
nodeLookup,
|
||||
site,
|
||||
memberDef,
|
||||
'scope-resolution: chain-receiver',
|
||||
seen,
|
||||
);
|
||||
if (ok) {
|
||||
emitted++;
|
||||
handledSites.add(siteKey);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Case 1: namespace receiver ───────────────────────────────
|
||||
const targetFile = namespaceTargets.get(receiverName);
|
||||
if (targetFile !== undefined) {
|
||||
const memberDef = findExportedDef(targetFile, memberName, parsedFiles);
|
||||
if (memberDef !== undefined) {
|
||||
const ok = tryEmitEdge(
|
||||
graph,
|
||||
scopes,
|
||||
nodeLookup,
|
||||
site,
|
||||
memberDef,
|
||||
'scope-resolution: namespace-receiver',
|
||||
seen,
|
||||
);
|
||||
if (ok) {
|
||||
emitted++;
|
||||
handledSites.add(siteKey);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Case 2: class-name receiver ──────────────────────────────
|
||||
const classDef = findClassBindingInScope(site.inScope, receiverName, scopes);
|
||||
if (classDef !== undefined) {
|
||||
const chain = [classDef.nodeId, ...scopes.methodDispatch.mroFor(classDef.nodeId)];
|
||||
let memberDef: SymbolDefinition | undefined;
|
||||
for (const ownerId of chain) {
|
||||
memberDef = findOwnedMember(ownerId, memberName, parsedFiles);
|
||||
if (memberDef !== undefined) break;
|
||||
}
|
||||
if (memberDef !== undefined) {
|
||||
const ok = tryEmitEdge(
|
||||
graph,
|
||||
scopes,
|
||||
nodeLookup,
|
||||
site,
|
||||
memberDef,
|
||||
'scope-resolution: class-receiver',
|
||||
seen,
|
||||
);
|
||||
if (ok) {
|
||||
emitted++;
|
||||
handledSites.add(siteKey);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Case 3: dotted typeBinding (`u: models.User`) ────────────
|
||||
const typeRef = findReceiverTypeBinding(site.inScope, receiverName, scopes);
|
||||
if (typeRef !== undefined && typeRef.rawName.includes('.')) {
|
||||
const [nsName, ...classNameParts] = typeRef.rawName.split('.');
|
||||
const className = classNameParts.join('.');
|
||||
const targetFile3 = namespaceTargets.get(nsName);
|
||||
if (targetFile3 !== undefined && className.length > 0) {
|
||||
const classDef3 = findExportedDef(targetFile3, className, parsedFiles);
|
||||
if (classDef3 !== undefined) {
|
||||
const memberDef = findOwnedMember(classDef3.nodeId, memberName, parsedFiles);
|
||||
if (memberDef !== undefined) {
|
||||
const ok = tryEmitEdge(
|
||||
graph,
|
||||
scopes,
|
||||
nodeLookup,
|
||||
site,
|
||||
memberDef,
|
||||
'scope-resolution: dotted-typebinding',
|
||||
seen,
|
||||
);
|
||||
if (ok) {
|
||||
emitted++;
|
||||
handledSites.add(siteKey);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Case 3b: chain-typebinding (`city → user.get_city`) ──────
|
||||
if (
|
||||
typeRef !== undefined &&
|
||||
typeRef.rawName.includes('.') &&
|
||||
!typeRef.rawName.includes('(') &&
|
||||
!namespaceTargets.has(typeRef.rawName.split('.')[0]!)
|
||||
) {
|
||||
const ownerDef = resolveCompoundReceiverClass(
|
||||
typeRef.rawName + '()',
|
||||
typeRef.declaredAtScope,
|
||||
scopes,
|
||||
parsedFiles,
|
||||
classScopeByDefId,
|
||||
{ fieldFallback },
|
||||
);
|
||||
if (ownerDef !== undefined) {
|
||||
const chain = [ownerDef.nodeId, ...scopes.methodDispatch.mroFor(ownerDef.nodeId)];
|
||||
let memberDef: SymbolDefinition | undefined;
|
||||
for (const ownerId of chain) {
|
||||
memberDef = findOwnedMember(ownerId, memberName, parsedFiles);
|
||||
if (memberDef !== undefined) break;
|
||||
}
|
||||
if (memberDef !== undefined) {
|
||||
const ok = tryEmitEdge(
|
||||
graph,
|
||||
scopes,
|
||||
nodeLookup,
|
||||
site,
|
||||
memberDef,
|
||||
'scope-resolution: chain-typebinding',
|
||||
seen,
|
||||
);
|
||||
if (ok) {
|
||||
emitted++;
|
||||
handledSites.add(siteKey);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Case 4: simple typeBinding (`u: U`) ──────────────────────
|
||||
if (typeRef !== undefined && !typeRef.rawName.includes('.')) {
|
||||
const ownerDef = findClassBindingInScope(site.inScope, typeRef.rawName, scopes);
|
||||
if (ownerDef !== undefined) {
|
||||
const chain = [ownerDef.nodeId, ...scopes.methodDispatch.mroFor(ownerDef.nodeId)];
|
||||
let memberDef: SymbolDefinition | undefined;
|
||||
for (const ownerId of chain) {
|
||||
memberDef = findOwnedMember(ownerId, memberName, parsedFiles);
|
||||
if (memberDef !== undefined) break;
|
||||
}
|
||||
if (memberDef !== undefined) {
|
||||
// For read/write ACCESSES, mirror the legacy DAG's reason
|
||||
// convention so consumers asserting `reason === 'write'`
|
||||
// keep working.
|
||||
const reason =
|
||||
site.kind === 'write' || site.kind === 'read'
|
||||
? site.kind
|
||||
: 'scope-resolution: typeref-receiver';
|
||||
const ok = tryEmitEdge(graph, scopes, nodeLookup, site, memberDef, reason, seen);
|
||||
if (ok) {
|
||||
emitted++;
|
||||
handledSites.add(siteKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return emitted;
|
||||
}
|
||||
|
|
@ -38,6 +38,12 @@ export {
|
|||
findExportedDef,
|
||||
} from './scope-walkers.js';
|
||||
export { emitFreeCallFallback } from './emit-free-call.js';
|
||||
export { emitReceiverBoundCalls } from './emit-receiver-bound.js';
|
||||
export {
|
||||
matchingOpenParen,
|
||||
resolveCompoundReceiverClass,
|
||||
type ResolveCompoundReceiverOptions,
|
||||
} from './emit-compound-receiver.js';
|
||||
export { followChainPostFinalize, propagateImportedReturnTypes } from './propagate-return-types.js';
|
||||
export { collectNamespaceTargets } from './namespace-targets.js';
|
||||
export { buildPopulatedMethodDispatch } from './method-dispatch-bridge.js';
|
||||
|
|
|
|||
|
|
@ -28,18 +28,14 @@
|
|||
|
||||
import type {
|
||||
ParsedFile,
|
||||
Reference,
|
||||
RegistryProviders,
|
||||
Scope,
|
||||
ScopeId,
|
||||
SymbolDefinition,
|
||||
TypeRef,
|
||||
WorkspaceIndex,
|
||||
} from 'gitnexus-shared';
|
||||
import type { KnowledgeGraph } from '../graph/types.js';
|
||||
import { extractParsedFile } from './scope-extractor-bridge.js';
|
||||
import { finalizeScopeModel } from './finalize-orchestrator.js';
|
||||
import type { ScopeResolutionIndexes } from './model/scope-resolution-indexes.js';
|
||||
import { resolveReferenceSites, type ResolveStats } from './resolve-references.js';
|
||||
import { pythonProvider } from './languages/python.js';
|
||||
import {
|
||||
|
|
@ -51,21 +47,16 @@ import {
|
|||
import {
|
||||
buildGraphNodeLookup,
|
||||
buildPopulatedMethodDispatch,
|
||||
collectNamespaceTargets,
|
||||
emitFreeCallFallback,
|
||||
emitImportEdges,
|
||||
emitReceiverBoundCalls,
|
||||
emitReferencesViaLookup,
|
||||
findClassBindingInScope,
|
||||
findEnclosingClassDef,
|
||||
findExportedDef,
|
||||
findExportedDefByName,
|
||||
findOwnedMember,
|
||||
findReceiverTypeBinding,
|
||||
propagateImportedReturnTypes,
|
||||
resolveDefGraphId,
|
||||
tryEmitEdge,
|
||||
type EmitProvider,
|
||||
type GraphNodeLookup,
|
||||
} from './emit-core/index.js';
|
||||
import { SupportedLanguages } from 'gitnexus-shared';
|
||||
|
||||
// ─── Public API ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -200,8 +191,8 @@ export function runPythonScopeResolution(
|
|||
indexes,
|
||||
parsedFiles,
|
||||
nodeLookup,
|
||||
referenceIndex,
|
||||
handledSites,
|
||||
pythonEmitProviderInline,
|
||||
);
|
||||
const freeCallExtras = emitFreeCallFallback(
|
||||
graph,
|
||||
|
|
@ -320,435 +311,6 @@ function buildPythonMro(
|
|||
return mroByDefId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit CALLS / ACCESSES edges for dotted references whose receiver is a
|
||||
* namespace-import binding (`import models; models.User()`) or a class
|
||||
* name in the call scope (`Dog.classify("dog")`).
|
||||
*
|
||||
* The shared `MethodRegistry.lookup` only walks `scope.typeBindings`
|
||||
* when resolving an explicit receiver. It never consults `scope.bindings`
|
||||
* for namespace/class-kind entries, nor does it follow an
|
||||
* `ImportEdge.targetModuleScope` for cross-module lookups. Rather than
|
||||
* widen the shared contract, this Python-specific pass closes the gap
|
||||
* with a direct receiver → target walk.
|
||||
*/
|
||||
function emitReceiverBoundCalls(
|
||||
graph: KnowledgeGraph,
|
||||
scopes: ScopeResolutionIndexes,
|
||||
parsedFiles: readonly ParsedFile[],
|
||||
nodeLookup: GraphNodeLookup,
|
||||
referenceIndex: { readonly bySourceScope: ReadonlyMap<ScopeId, readonly Reference[]> },
|
||||
handledSites: Set<string>,
|
||||
): number {
|
||||
let emitted = 0;
|
||||
// `seen` is the per-pass dedup so the multiple Cases below don't
|
||||
// double-emit if two of them resolve the same site to the same
|
||||
// target. Pre-seeding from the shared resolver was historically
|
||||
// useful when emit-references ran FIRST, but now the order is
|
||||
// reversed (emit-references comes after this pass and uses
|
||||
// `handledSites` to skip what we processed). Keeping the pre-seed
|
||||
// would suppress legitimate emissions for sites the shared resolver
|
||||
// happened to also resolve.
|
||||
const seen = new Set<string>();
|
||||
void referenceIndex; // kept in signature for parity with future passes
|
||||
|
||||
// Class def → Class scope map (for field-chain field-type lookup).
|
||||
// The class scope's `ownedDefs` contains the Class def per pass2's
|
||||
// structural-ownership rule.
|
||||
const classScopeByDefId = new Map<string, Scope>();
|
||||
for (const p of parsedFiles) {
|
||||
for (const scope of p.scopes) {
|
||||
if (scope.kind !== 'Class') continue;
|
||||
const cd = scope.ownedDefs.find((d) => d.type === 'Class');
|
||||
if (cd !== undefined) classScopeByDefId.set(cd.nodeId, scope);
|
||||
}
|
||||
}
|
||||
|
||||
for (const parsed of parsedFiles) {
|
||||
const namespaceTargets = collectNamespaceTargets(parsed, scopes);
|
||||
|
||||
for (const site of parsed.referenceSites) {
|
||||
if (site.kind !== 'call' && site.kind !== 'read' && site.kind !== 'write') continue;
|
||||
if (site.explicitReceiver === undefined) continue;
|
||||
|
||||
const receiverName = site.explicitReceiver.name;
|
||||
const memberName = site.name;
|
||||
const siteKey = `${parsed.filePath}:${site.atRange.startLine}:${site.atRange.startCol}`;
|
||||
|
||||
// ── super() — resolve to the enclosing class's PARENT (first MRO entry).
|
||||
// Python's `super()` inside a method dispatches up the MRO chain.
|
||||
if (/^super\s*\(/.test(receiverName)) {
|
||||
const enclosingClass = findEnclosingClassDef(site.inScope, scopes);
|
||||
if (enclosingClass !== undefined) {
|
||||
const ancestors = scopes.methodDispatch.mroFor(enclosingClass.nodeId);
|
||||
let memberDef: SymbolDefinition | undefined;
|
||||
for (const ownerId of ancestors) {
|
||||
memberDef = findOwnedMember(ownerId, memberName, parsedFiles);
|
||||
if (memberDef !== undefined) break;
|
||||
}
|
||||
if (memberDef !== undefined) {
|
||||
const ok = tryEmitEdge(
|
||||
graph,
|
||||
scopes,
|
||||
nodeLookup,
|
||||
site,
|
||||
memberDef,
|
||||
'python-scope: super-receiver',
|
||||
seen,
|
||||
);
|
||||
if (ok) {
|
||||
emitted++;
|
||||
handledSites.add(siteKey);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Case 0: compound receiver (`user.address.save()` or
|
||||
// `svc.get_user().save()`) — walk the dotted/call chain,
|
||||
// resolving each segment to a class via field types or
|
||||
// method return types.
|
||||
if (receiverName.includes('.') || receiverName.includes('(')) {
|
||||
const currentClass = resolveCompoundReceiverClass(
|
||||
receiverName,
|
||||
site.inScope,
|
||||
scopes,
|
||||
parsedFiles,
|
||||
classScopeByDefId,
|
||||
);
|
||||
if (currentClass !== undefined) {
|
||||
const chain = [currentClass.nodeId, ...scopes.methodDispatch.mroFor(currentClass.nodeId)];
|
||||
let memberDef: SymbolDefinition | undefined;
|
||||
for (const ownerId of chain) {
|
||||
memberDef = findOwnedMember(ownerId, memberName, parsedFiles);
|
||||
if (memberDef !== undefined) break;
|
||||
}
|
||||
if (memberDef !== undefined) {
|
||||
const ok = tryEmitEdge(
|
||||
graph,
|
||||
scopes,
|
||||
nodeLookup,
|
||||
site,
|
||||
memberDef,
|
||||
'python-scope: chain-receiver',
|
||||
seen,
|
||||
);
|
||||
if (ok) {
|
||||
emitted++;
|
||||
handledSites.add(siteKey);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Case 1: namespace receiver (`import models; models.X()`) ─
|
||||
const targetFile = namespaceTargets.get(receiverName);
|
||||
if (targetFile !== undefined) {
|
||||
const memberDef = findExportedDef(targetFile, memberName, parsedFiles);
|
||||
if (memberDef !== undefined) {
|
||||
const ok = tryEmitEdge(
|
||||
graph,
|
||||
scopes,
|
||||
nodeLookup,
|
||||
site,
|
||||
memberDef,
|
||||
'python-scope: namespace-receiver',
|
||||
seen,
|
||||
);
|
||||
if (ok) {
|
||||
emitted++;
|
||||
handledSites.add(siteKey);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Case 2: class-name receiver (`Dog.classify()`) ──────────
|
||||
const classDef = findClassBindingInScope(site.inScope, receiverName, scopes);
|
||||
if (classDef !== undefined) {
|
||||
// Walk the MRO so inherited static/class methods resolve.
|
||||
const chain = [classDef.nodeId, ...scopes.methodDispatch.mroFor(classDef.nodeId)];
|
||||
let memberDef: SymbolDefinition | undefined;
|
||||
for (const ownerId of chain) {
|
||||
memberDef = findOwnedMember(ownerId, memberName, parsedFiles);
|
||||
if (memberDef !== undefined) break;
|
||||
}
|
||||
if (memberDef !== undefined) {
|
||||
const ok = tryEmitEdge(
|
||||
graph,
|
||||
scopes,
|
||||
nodeLookup,
|
||||
site,
|
||||
memberDef,
|
||||
'python-scope: class-receiver',
|
||||
seen,
|
||||
);
|
||||
if (ok) {
|
||||
emitted++;
|
||||
handledSites.add(siteKey);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Case 3: receiver has a dotted typeBinding (`u: models.User`) ──
|
||||
const typeRef = findReceiverTypeBinding(site.inScope, receiverName, scopes);
|
||||
if (typeRef !== undefined && typeRef.rawName.includes('.')) {
|
||||
const [nsName, ...classNameParts] = typeRef.rawName.split('.');
|
||||
const className = classNameParts.join('.');
|
||||
const targetFile3 = namespaceTargets.get(nsName);
|
||||
if (targetFile3 !== undefined && className.length > 0) {
|
||||
const classDef3 = findExportedDef(targetFile3, className, parsedFiles);
|
||||
if (classDef3 !== undefined) {
|
||||
const memberDef = findOwnedMember(classDef3.nodeId, memberName, parsedFiles);
|
||||
if (memberDef !== undefined) {
|
||||
const ok = tryEmitEdge(
|
||||
graph,
|
||||
scopes,
|
||||
nodeLookup,
|
||||
site,
|
||||
memberDef,
|
||||
'python-scope: dotted-typebinding',
|
||||
seen,
|
||||
);
|
||||
if (ok) {
|
||||
emitted++;
|
||||
handledSites.add(siteKey);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Case 3b: receiver's typeBinding is a method-call chain
|
||||
// (`city → user.get_city`). The constructor-inferred capture for
|
||||
// `city = user.get_city()` stores the attribute text without
|
||||
// parens. Treat as a call-shape and run through the compound
|
||||
// resolver, which now also field-walks when the method isn't
|
||||
// owned by the receiver's class.
|
||||
if (
|
||||
typeRef !== undefined &&
|
||||
typeRef.rawName.includes('.') &&
|
||||
!typeRef.rawName.includes('(') &&
|
||||
!namespaceTargets.has(typeRef.rawName.split('.')[0]!)
|
||||
) {
|
||||
const ownerDef = resolveCompoundReceiverClass(
|
||||
typeRef.rawName + '()',
|
||||
typeRef.declaredAtScope,
|
||||
scopes,
|
||||
parsedFiles,
|
||||
classScopeByDefId,
|
||||
);
|
||||
if (ownerDef !== undefined) {
|
||||
const chain = [ownerDef.nodeId, ...scopes.methodDispatch.mroFor(ownerDef.nodeId)];
|
||||
let memberDef: SymbolDefinition | undefined;
|
||||
for (const ownerId of chain) {
|
||||
memberDef = findOwnedMember(ownerId, memberName, parsedFiles);
|
||||
if (memberDef !== undefined) break;
|
||||
}
|
||||
if (memberDef !== undefined) {
|
||||
const ok = tryEmitEdge(
|
||||
graph,
|
||||
scopes,
|
||||
nodeLookup,
|
||||
site,
|
||||
memberDef,
|
||||
'python-scope: chain-typebinding',
|
||||
seen,
|
||||
);
|
||||
if (ok) {
|
||||
emitted++;
|
||||
handledSites.add(siteKey);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Case 4: simple typeBinding (`u: U` where U is aliased import)
|
||||
if (typeRef !== undefined && !typeRef.rawName.includes('.')) {
|
||||
const ownerDef = findClassBindingInScope(site.inScope, typeRef.rawName, scopes);
|
||||
if (ownerDef !== undefined) {
|
||||
const chain = [ownerDef.nodeId, ...scopes.methodDispatch.mroFor(ownerDef.nodeId)];
|
||||
let memberDef: SymbolDefinition | undefined;
|
||||
for (const ownerId of chain) {
|
||||
memberDef = findOwnedMember(ownerId, memberName, parsedFiles);
|
||||
if (memberDef !== undefined) break;
|
||||
}
|
||||
if (memberDef !== undefined) {
|
||||
// For read/write ACCESSES, mirror the legacy DAG's reason
|
||||
// convention (just the kind word) so consumers asserting
|
||||
// `reason === 'write'` keep working.
|
||||
const reason =
|
||||
site.kind === 'write' || site.kind === 'read'
|
||||
? site.kind
|
||||
: 'python-scope: typeref-receiver';
|
||||
const ok = tryEmitEdge(graph, scopes, nodeLookup, site, memberDef, reason, seen);
|
||||
if (ok) {
|
||||
emitted++;
|
||||
handledSites.add(siteKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return emitted;
|
||||
}
|
||||
|
||||
/** Max depth for compound-receiver chain resolution (`a().b().c().d()`).
|
||||
* Practical Python rarely exceeds 3-4 hops; the cap just prevents
|
||||
* pathological recursion if the receiver text turns out to be malformed. */
|
||||
const COMPOUND_RECEIVER_MAX_DEPTH = 4;
|
||||
|
||||
/**
|
||||
* Resolve a compound-receiver expression's TYPE (the class def of the
|
||||
* value it produces). Handles three shapes:
|
||||
* - bare identifier `name` — look up via typeBinding chain
|
||||
* - dotted `obj.field[.field]…` — walk fields via class-scope typeBindings
|
||||
* - call `expr.method()` — recurse into expr, find method's return-type
|
||||
* typeBinding on its class, resolve to a class
|
||||
*
|
||||
* Returns the class `SymbolDefinition` or undefined if the chain dead-ends.
|
||||
* Depth-capped at COMPOUND_RECEIVER_MAX_DEPTH.
|
||||
*/
|
||||
function resolveCompoundReceiverClass(
|
||||
receiverText: string,
|
||||
inScope: ScopeId,
|
||||
scopes: ScopeResolutionIndexes,
|
||||
parsedFiles: readonly ParsedFile[],
|
||||
classScopeByDefId: ReadonlyMap<string, Scope>,
|
||||
depth = 0,
|
||||
): SymbolDefinition | undefined {
|
||||
if (depth > COMPOUND_RECEIVER_MAX_DEPTH) return undefined;
|
||||
const text = receiverText.trim();
|
||||
if (text.length === 0) return undefined;
|
||||
|
||||
// Bare identifier — resolve via typeBinding then class lookup.
|
||||
if (!text.includes('.') && !text.includes('(')) {
|
||||
const tb = findReceiverTypeBinding(inScope, text, scopes);
|
||||
if (tb === undefined) return undefined;
|
||||
return findClassBindingInScope(tb.declaredAtScope, tb.rawName, scopes);
|
||||
}
|
||||
|
||||
// Trailing `()` — call expression. Strip it and resolve the function
|
||||
// expression's return type. We only handle the canonical `f()` /
|
||||
// `obj.method()` shape; nested-arg expressions like `f(g())` are
|
||||
// out of scope for V1 (depth-capped recursion catches infinite loops).
|
||||
if (text.endsWith(')')) {
|
||||
// Find the matching `(` by walking from end with a depth counter
|
||||
// so nested parens in args don't fool us.
|
||||
const openIdx = matchingOpenParen(text);
|
||||
if (openIdx === -1) return undefined;
|
||||
const fnExpr = text.slice(0, openIdx).trim();
|
||||
if (fnExpr.length === 0) return undefined;
|
||||
|
||||
// Split into receiver and method name on the LAST dot.
|
||||
const lastDot = fnExpr.lastIndexOf('.');
|
||||
if (lastDot === -1) {
|
||||
// Free call `name()`. Look up function in scope, then its
|
||||
// return-type typeBinding (which lives in the function's
|
||||
// enclosing scope per Pass 4 hoist).
|
||||
const fnDef = findExportedDefByName(fnExpr, inScope, scopes, parsedFiles);
|
||||
if (fnDef === undefined) return undefined;
|
||||
// The return-type binding key == the function's simple name in
|
||||
// the scope where the function is bound. Walk for it.
|
||||
const retType = findReceiverTypeBinding(inScope, fnExpr, scopes);
|
||||
if (retType === undefined) return undefined;
|
||||
return findClassBindingInScope(retType.declaredAtScope, retType.rawName, scopes);
|
||||
}
|
||||
// `obj.method()` — resolve obj's class, look up method, then its
|
||||
// return-type typeBinding on that class scope.
|
||||
const objExpr = fnExpr.slice(0, lastDot);
|
||||
const methodName = fnExpr.slice(lastDot + 1);
|
||||
const objClass = resolveCompoundReceiverClass(
|
||||
objExpr,
|
||||
inScope,
|
||||
scopes,
|
||||
parsedFiles,
|
||||
classScopeByDefId,
|
||||
depth + 1,
|
||||
);
|
||||
if (objClass === undefined) return undefined;
|
||||
// Walk the MRO so methods inherited from ancestors resolve. A class
|
||||
// owning the method itself doesn't always exist — `class C(B)` may
|
||||
// inherit `greet` from B (or B's parent A), and the return-type
|
||||
// typeBinding lives on the SCOPE OF THE CLASS THAT OWNS THE METHOD.
|
||||
let retType: TypeRef | undefined;
|
||||
const ownerChain = [objClass.nodeId, ...scopes.methodDispatch.mroFor(objClass.nodeId)];
|
||||
for (const ownerId of ownerChain) {
|
||||
const cs = classScopeByDefId.get(ownerId);
|
||||
const candidate = cs?.typeBindings.get(methodName);
|
||||
if (candidate !== undefined) {
|
||||
retType = candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Field-fallback: if the receiver class has no `methodName` itself,
|
||||
// walk its fields and try the same lookup on each field's type.
|
||||
// Matches the "unified fixpoint" behavior tested by the
|
||||
// method-chain fixture where `user.get_city()` reaches
|
||||
// `Address.get_city` through User's `address: Address` field.
|
||||
if (retType === undefined) {
|
||||
const objCs = classScopeByDefId.get(objClass.nodeId);
|
||||
if (objCs !== undefined) {
|
||||
for (const [, fieldType] of objCs.typeBindings) {
|
||||
const fieldClass = findClassBindingInScope(
|
||||
fieldType.declaredAtScope,
|
||||
fieldType.rawName,
|
||||
scopes,
|
||||
);
|
||||
if (fieldClass === undefined) continue;
|
||||
const fcs = classScopeByDefId.get(fieldClass.nodeId);
|
||||
const candidate = fcs?.typeBindings.get(methodName);
|
||||
if (candidate !== undefined) {
|
||||
retType = candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (retType === undefined) return undefined;
|
||||
return findClassBindingInScope(retType.declaredAtScope, retType.rawName, scopes);
|
||||
}
|
||||
|
||||
// Pure dotted access `obj.field[.field]…` — walk fields.
|
||||
const parts = text.split('.');
|
||||
const head = parts[0]!;
|
||||
const headType = findReceiverTypeBinding(inScope, head, scopes);
|
||||
let currentClass: SymbolDefinition | undefined = headType
|
||||
? findClassBindingInScope(headType.declaredAtScope, headType.rawName, scopes)
|
||||
: undefined;
|
||||
for (let i = 1; i < parts.length && currentClass !== undefined; i++) {
|
||||
const fieldName = parts[i]!;
|
||||
const cs = classScopeByDefId.get(currentClass.nodeId);
|
||||
const fieldType = cs?.typeBindings.get(fieldName);
|
||||
if (fieldType === undefined) return undefined;
|
||||
currentClass = findClassBindingInScope(fieldType.declaredAtScope, fieldType.rawName, scopes);
|
||||
}
|
||||
return currentClass;
|
||||
}
|
||||
|
||||
/** Find the index of the `(` that matches the trailing `)` of a
|
||||
* call-expression text. Returns -1 if unbalanced. */
|
||||
function matchingOpenParen(text: string): number {
|
||||
if (!text.endsWith(')')) return -1;
|
||||
let depth = 0;
|
||||
for (let i = text.length - 1; i >= 0; i--) {
|
||||
const ch = text[i];
|
||||
if (ch === ')') depth++;
|
||||
else if (ch === '(') {
|
||||
depth--;
|
||||
if (depth === 0) return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Populate `ownerId` on Method/Function/Field defs that live structurally
|
||||
* inside a `Class` scope.
|
||||
|
|
@ -794,3 +356,16 @@ function populateMethodOwnerIds(parsed: ParsedFile): void {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Minimal `EmitProvider` carrying only the hooks the receiver-bound
|
||||
* pass currently consults. The full provider (with mergeBindings,
|
||||
* resolveImportTarget, arityCompatibility, buildMro, populateOwners)
|
||||
* lands in G-Unit 6 when it moves to `languages/python/emit/`. */
|
||||
const pythonEmitProviderInline: Pick<
|
||||
EmitProvider,
|
||||
'language' | 'isSuperReceiver' | 'fieldFallbackOnMethodLookup'
|
||||
> = {
|
||||
language: SupportedLanguages.Python,
|
||||
isSuperReceiver: (text) => /^super\s*\(/.test(text),
|
||||
fieldFallbackOnMethodLookup: true,
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue