mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-21 00:21:30 +00:00
refactor(emit-core): EmitProvider interface + promote 5 generic helpers
G-Units 1-2 of the emit-pipeline generalization plan. Adds: - emit-core/emit-provider.ts — typed EmitProvider contract (6 required + 2 optional fields). Will be consumed by the generic orchestrator in G-Unit 6. Documents the LanguageProvider vs EmitProvider boundary. - emit-core/emit-free-call.ts — emitFreeCallFallback promoted as-is (drops the unused referenceIndex pre-seed parameter; underscore-prefixed to keep the signature compatible). - emit-core/propagate-return-types.ts — propagateImportedReturnTypes + followChainPostFinalize. Documents the mutation contract (Invariant I3 + I6 from the plan): runs after finalize, before resolve, mutates the non-frozen Scope.typeBindings map. - emit-core/scope-walkers.ts: + findEnclosingClassDef + findExportedDefByName. Both were already generic in the Python source. python-scope-emit.ts shrinks 1055 → 799 lines (–256). Imports the promoted helpers from emit-core. No behavior change. Verification: - REGISTRY_PRIMARY_PYTHON=0 (legacy): 191/191. - REGISTRY_PRIMARY_PYTHON=1 (registry): 191/191. - tsc --noEmit clean.
This commit is contained in:
parent
61acc9b107
commit
791acc330e
6 changed files with 440 additions and 263 deletions
71
gitnexus/src/core/ingestion/emit-core/emit-free-call.ts
Normal file
71
gitnexus/src/core/ingestion/emit-core/emit-free-call.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
/**
|
||||
* Emit CALLS edges for free-call reference sites whose target is
|
||||
* imported (or otherwise visible only via post-finalize scope.bindings).
|
||||
*
|
||||
* The shared `MethodRegistry.lookup` only consults `scope.bindings`
|
||||
* (pre-finalize / local-only) for free calls. Cross-file imports land
|
||||
* in `indexes.bindings` (post-finalize). Without this fallback, every
|
||||
* `from x import f; f()` resolves to "unresolved".
|
||||
*
|
||||
* **Free-call dedup contract (Contract Invariant I2):** free calls
|
||||
* collapse to one CALLS edge per (caller, target) pair regardless of
|
||||
* how many call sites the caller contains. Mirrors the legacy DAG's
|
||||
* dedup semantics (what the `default-params` / `variadic` / `overload`
|
||||
* fixtures expect). Member calls keep position-based dedup elsewhere.
|
||||
*
|
||||
* Generic; promoted from `python-scope-emit.ts` per the emit-core
|
||||
* generalization plan.
|
||||
*/
|
||||
|
||||
import type { ParsedFile, Reference, ScopeId } from 'gitnexus-shared';
|
||||
import type { KnowledgeGraph } from '../../graph/types.js';
|
||||
import type { ScopeResolutionIndexes } from '../model/scope-resolution-indexes.js';
|
||||
import type { GraphNodeLookup } from './graph-node-lookup.js';
|
||||
import { resolveCallerGraphId, resolveDefGraphId } from './graph-id.js';
|
||||
import { findCallableBindingInScope } from './scope-walkers.js';
|
||||
|
||||
export function emitFreeCallFallback(
|
||||
graph: KnowledgeGraph,
|
||||
scopes: ScopeResolutionIndexes,
|
||||
parsedFiles: readonly ParsedFile[],
|
||||
nodeLookup: GraphNodeLookup,
|
||||
_referenceIndex: { readonly bySourceScope: ReadonlyMap<ScopeId, readonly Reference[]> },
|
||||
handledSites: Set<string>,
|
||||
): number {
|
||||
let emitted = 0;
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const parsed of parsedFiles) {
|
||||
for (const site of parsed.referenceSites) {
|
||||
if (site.kind !== 'call') continue;
|
||||
if (site.explicitReceiver !== undefined) continue;
|
||||
|
||||
const fnDef = findCallableBindingInScope(site.inScope, site.name, scopes);
|
||||
if (fnDef === undefined) continue;
|
||||
|
||||
const callerGraphId = resolveCallerGraphId(site.inScope, scopes, nodeLookup);
|
||||
if (callerGraphId === undefined) continue;
|
||||
const tgtGraphId = resolveDefGraphId(fnDef.filePath, fnDef, nodeLookup);
|
||||
if (tgtGraphId === undefined) continue;
|
||||
// Always mark the site as handled — even when the dedup-collapse
|
||||
// means we don't add a new edge — so `emit-references` skips its
|
||||
// potentially-wrong fallback for the same site.
|
||||
handledSites.add(`${parsed.filePath}:${site.atRange.startLine}:${site.atRange.startCol}`);
|
||||
const relId = `rel:CALLS:${callerGraphId}->${tgtGraphId}`;
|
||||
if (seen.has(relId)) continue;
|
||||
seen.add(relId);
|
||||
graph.addRelationship({
|
||||
id: relId,
|
||||
sourceId: callerGraphId,
|
||||
targetId: tgtGraphId,
|
||||
type: 'CALLS',
|
||||
confidence: 0.85,
|
||||
// Match legacy DAG's reason convention so consumers that
|
||||
// assert `reason === 'import-resolved'` keep working.
|
||||
reason: fnDef.filePath !== parsed.filePath ? 'import-resolved' : 'local-call',
|
||||
});
|
||||
emitted++;
|
||||
}
|
||||
}
|
||||
return emitted;
|
||||
}
|
||||
154
gitnexus/src/core/ingestion/emit-core/emit-provider.ts
Normal file
154
gitnexus/src/core/ingestion/emit-core/emit-provider.ts
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
/**
|
||||
* `EmitProvider` — the per-language contract consumed by the generic
|
||||
* scope-resolution orchestrator (`runScopeResolution`).
|
||||
*
|
||||
* **Two distinct provider contracts:** the codebase has both
|
||||
* `LanguageProvider` (in `language-provider.ts`) and this `EmitProvider`.
|
||||
* Their lifecycles differ:
|
||||
*
|
||||
* - `LanguageProvider` is the **parsing-side** contract — how to emit
|
||||
* captures, classify scopes, interpret imports / typeBindings. ~40
|
||||
* fields covering both legacy and new pipelines. Consumed by
|
||||
* `ScopeExtractor`.
|
||||
* - `EmitProvider` is the **emit-side** contract — how the resolution
|
||||
* pipeline dispatches references to graph edges. 6 required + 2
|
||||
* optional fields. Consumed by `runScopeResolution`.
|
||||
*
|
||||
* They share three concept names (`arityCompatibility`, `mergeBindings`,
|
||||
* `resolveImportTarget`) because the emit pipeline reuses a few
|
||||
* finalize hooks. Per-language wiring passes the SAME function
|
||||
* reference through both interfaces — there is no second copy of the
|
||||
* logic. Rationale for not collapsing them: lifecycles differ
|
||||
* (parsing-side runs once per file at extract time, emit-side runs
|
||||
* once per workspace at resolve time), and merging would create a
|
||||
* god-interface that complicates future migrations.
|
||||
*
|
||||
* **Reference implementation:** `languages/python/emit/index.ts` —
|
||||
* `pythonEmitProvider` is the canonical example. Read that file when
|
||||
* migrating a new language; this interface lists the 6 fields that
|
||||
* implementation populates.
|
||||
*
|
||||
* Plan: `docs/plans/2026-04-20-001-refactor-emit-pipeline-generalization-plan.md`.
|
||||
*/
|
||||
|
||||
import type {
|
||||
BindingRef,
|
||||
Callsite,
|
||||
ParsedFile,
|
||||
Scope,
|
||||
ScopeId,
|
||||
SupportedLanguages,
|
||||
SymbolDefinition,
|
||||
} from 'gitnexus-shared';
|
||||
import type { KnowledgeGraph } from '../../graph/types.js';
|
||||
import type { GraphNodeLookup } from './graph-node-lookup.js';
|
||||
|
||||
/** A LinearizeStrategy receives the full ancestor map so C3-style
|
||||
* algorithms (which need to merge each parent's MRO) can implement
|
||||
* themselves. Python's depth-first first-seen only consumes
|
||||
* `directParents` and `parentsByDefId`. */
|
||||
export type LinearizeStrategy = (
|
||||
classDefId: string,
|
||||
directParents: readonly string[],
|
||||
parentsByDefId: ReadonlyMap<string, readonly string[]>,
|
||||
) => string[];
|
||||
|
||||
/** Result of `EmitProvider.arityCompatibility` — mirrors `RegistryProviders.arityCompatibility`. */
|
||||
export type ArityVerdict = 'compatible' | 'unknown' | 'incompatible';
|
||||
|
||||
export interface EmitProvider {
|
||||
/** Identity for telemetry + per-language flag check. */
|
||||
readonly language: SupportedLanguages;
|
||||
|
||||
// ─── Pipeline hooks ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Resolve an import statement's `targetRaw` (e.g. `models.user`,
|
||||
* `./helpers`) into an absolute repo-relative file path, or `null`
|
||||
* for unresolvable / external modules.
|
||||
*
|
||||
* Called once per `ParsedImport` during `finalizeScopeModel`. The
|
||||
* Python implementation wraps `resolvePythonImportTarget`.
|
||||
*/
|
||||
resolveImportTarget(targetRaw: string, fromFile: string): string | null;
|
||||
|
||||
/**
|
||||
* Per-scope binding-merge precedence. The shared finalize pass
|
||||
* collects bindings from multiple sources (local declarations,
|
||||
* imports, namespace, wildcard, reexport) and asks the language
|
||||
* how to order them.
|
||||
*
|
||||
* Python uses LEGB: local > import / namespace / reexport > wildcard.
|
||||
*/
|
||||
mergeBindings(
|
||||
existing: readonly BindingRef[],
|
||||
incoming: readonly BindingRef[],
|
||||
scopeId: ScopeId,
|
||||
): BindingRef[];
|
||||
|
||||
/**
|
||||
* Per-language arity compatibility between a callsite and a
|
||||
* candidate def. The shared `MethodRegistry.lookup` consults this
|
||||
* to penalize incompatible candidates without disqualifying them
|
||||
* outright. Note arg order — `(callsite, def)` matches the
|
||||
* `RegistryProviders` contract; some legacy provider impls use
|
||||
* `(def, callsite)` and need an adapter at the wiring site.
|
||||
*/
|
||||
arityCompatibility(callsite: Callsite, def: SymbolDefinition): ArityVerdict;
|
||||
|
||||
// ─── Per-language strategies ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Compute the method-dispatch order for every Class def in the
|
||||
* workspace. Python uses depth-first first-seen via
|
||||
* `pythonLinearize`; future languages may use C3 (Ruby, Python's
|
||||
* real MRO when we go beyond the simplified walk), single-
|
||||
* inheritance only (Java), or empty-map (languages without
|
||||
* inheritance).
|
||||
*/
|
||||
buildMro(
|
||||
graph: KnowledgeGraph,
|
||||
parsedFiles: readonly ParsedFile[],
|
||||
nodeLookup: GraphNodeLookup,
|
||||
): Map<string /* DefId */, string[] /* ancestor DefIds */>;
|
||||
|
||||
/**
|
||||
* Mutate `parsed.localDefs[i].ownerId` to point at the structural
|
||||
* owner. Python's rule: methods (Function defs whose parent scope
|
||||
* is Class) AND class-body fields (defs in Class scopes) are owned
|
||||
* by the enclosing class. Other languages may have richer rules
|
||||
* (e.g., Java inner-class qualification).
|
||||
*/
|
||||
populateOwners(parsed: ParsedFile): void;
|
||||
|
||||
/**
|
||||
* Recognize a `super(...)`-style receiver text. Python returns
|
||||
* `/^super\s*\(/.test(t)`. Java returns `t === 'super'`. C++ may
|
||||
* also need `this` capture. Languages without inheritance return
|
||||
* constant `false`.
|
||||
*/
|
||||
isSuperReceiver(receiverText: string): boolean;
|
||||
|
||||
// ─── Optional toggles ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Whether the orchestrator should run `propagateImportedReturnTypes`
|
||||
* after finalize. Default `true`. TypeScript with explicit type
|
||||
* exports may want a different propagation strategy and opt out.
|
||||
*/
|
||||
readonly propagatesReturnTypesAcrossImports?: boolean;
|
||||
|
||||
/**
|
||||
* Whether the compound-receiver resolver should fall back to
|
||||
* walking field types when method lookup on the receiver's class
|
||||
* fails (the "Phase-9C unified fixpoint" heuristic). Default
|
||||
* `true`. Strictly-typed languages should set `false` because the
|
||||
* heuristic can produce edges that wouldn't survive a real type
|
||||
* check.
|
||||
*/
|
||||
readonly fieldFallbackOnMethodLookup?: boolean;
|
||||
}
|
||||
|
||||
// Re-export Scope so consumers don't need to dig into `gitnexus-shared`
|
||||
// for the type they're already using transitively.
|
||||
export type { Scope };
|
||||
|
|
@ -32,8 +32,13 @@ export {
|
|||
findReceiverTypeBinding,
|
||||
findClassBindingInScope,
|
||||
findCallableBindingInScope,
|
||||
findEnclosingClassDef,
|
||||
findExportedDefByName,
|
||||
findOwnedMember,
|
||||
findExportedDef,
|
||||
} from './scope-walkers.js';
|
||||
export { emitFreeCallFallback } from './emit-free-call.js';
|
||||
export { followChainPostFinalize, propagateImportedReturnTypes } from './propagate-return-types.js';
|
||||
export { collectNamespaceTargets } from './namespace-targets.js';
|
||||
export { buildPopulatedMethodDispatch } from './method-dispatch-bridge.js';
|
||||
export type { ArityVerdict, EmitProvider, LinearizeStrategy } from './emit-provider.js';
|
||||
|
|
|
|||
129
gitnexus/src/core/ingestion/emit-core/propagate-return-types.ts
Normal file
129
gitnexus/src/core/ingestion/emit-core/propagate-return-types.ts
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
/**
|
||||
* Cross-file return-type typeBinding propagation + post-finalize
|
||||
* chain re-follow.
|
||||
*
|
||||
* **Why this lives in emit-core:** the algorithm is language-agnostic.
|
||||
* Every language with cross-file callable imports needs the same
|
||||
* mirror-binding step, otherwise `u = f(); u.save()` only resolves
|
||||
* when `f` is in the same file as the call.
|
||||
*
|
||||
* **Mutation contract (Contract Invariant I3 + I6):**
|
||||
* - Mutates `Scope.typeBindings` (a plain `new Map(...)` from
|
||||
* `draftToScope`, NOT frozen — intentional, do not freeze).
|
||||
* - MUST run AFTER `finalizeScopeModel` (so `indexes.bindings` is
|
||||
* populated) but BEFORE `resolveReferenceSites` (so resolution
|
||||
* sees the propagated types).
|
||||
*
|
||||
* Generic; promoted from `python-scope-emit.ts` per the emit-core
|
||||
* generalization plan.
|
||||
*/
|
||||
|
||||
import type { ParsedFile, Scope, ScopeId, TypeRef } from 'gitnexus-shared';
|
||||
import type { ScopeResolutionIndexes } from '../model/scope-resolution-indexes.js';
|
||||
|
||||
/** Max chain depth for the post-finalize re-follow. */
|
||||
const RECHAIN_MAX_DEPTH = 8;
|
||||
|
||||
/** Walk `ref.rawName` through the scope chain's typeBindings looking
|
||||
* for a terminal class-like rawName. Mirrors the in-extractor
|
||||
* `followChainedRef` but operates on post-finalize Scope objects so
|
||||
* it can see imported return-types propagated by
|
||||
* `propagateImportedReturnTypes`. */
|
||||
export function followChainPostFinalize(
|
||||
start: TypeRef,
|
||||
fromScopeId: ScopeId,
|
||||
scopes: ScopeResolutionIndexes,
|
||||
): TypeRef {
|
||||
let current = start;
|
||||
const visited = new Set<string>();
|
||||
for (let depth = 0; depth < RECHAIN_MAX_DEPTH; depth++) {
|
||||
if (current.rawName.includes('.')) return current;
|
||||
let scopeId: ScopeId | null = fromScopeId;
|
||||
let next: TypeRef | undefined;
|
||||
while (scopeId !== null) {
|
||||
const scope = scopes.scopeTree.getScope(scopeId);
|
||||
if (scope === undefined) break;
|
||||
next = scope.typeBindings.get(current.rawName);
|
||||
if (next !== undefined && next !== current) break;
|
||||
next = undefined;
|
||||
scopeId = scope.parent;
|
||||
}
|
||||
if (next === undefined) return current;
|
||||
if (visited.has(next.rawName)) return current;
|
||||
visited.add(next.rawName);
|
||||
current = next;
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy return-type typeBindings across module boundaries via import
|
||||
* bindings. For each module-scope import like `from x import f`, look
|
||||
* up `f` in the source file's module-scope typeBindings (which carries
|
||||
* `f → ReturnType` from the language's return-type annotation
|
||||
* capture) and mirror that binding into the importer's module scope.
|
||||
*
|
||||
* After propagation, re-runs the chain-follow on every scope's
|
||||
* typeBindings — the in-extractor pass-4 ran before propagation and
|
||||
* missed any chain whose terminal lived in a foreign file.
|
||||
*/
|
||||
export function propagateImportedReturnTypes(
|
||||
parsedFiles: readonly ParsedFile[],
|
||||
indexes: ScopeResolutionIndexes,
|
||||
): void {
|
||||
// Index module scopes by filePath for fast cross-file lookup.
|
||||
const moduleScopeByFile = new Map<string, Scope>();
|
||||
for (const parsed of parsedFiles) {
|
||||
const moduleScope = parsed.scopes.find((s) => s.kind === 'Module');
|
||||
if (moduleScope !== undefined) moduleScopeByFile.set(parsed.filePath, moduleScope);
|
||||
}
|
||||
|
||||
for (const parsed of parsedFiles) {
|
||||
const importerModule = moduleScopeByFile.get(parsed.filePath);
|
||||
if (importerModule === undefined) continue;
|
||||
const finalizedBindings = indexes.bindings.get(importerModule.id);
|
||||
if (finalizedBindings === undefined) continue;
|
||||
|
||||
for (const [localName, refs] of finalizedBindings) {
|
||||
// Skip if importer already has a typeBinding for this name (e.g.
|
||||
// an explicit local annotation should win over import-derived).
|
||||
if (importerModule.typeBindings.has(localName)) continue;
|
||||
|
||||
for (const ref of refs) {
|
||||
if (ref.origin !== 'import' && ref.origin !== 'reexport') continue;
|
||||
const sourceModule = moduleScopeByFile.get(ref.def.filePath);
|
||||
if (sourceModule === undefined) continue;
|
||||
|
||||
// The source file's typeBinding is keyed by the def's simple
|
||||
// name (e.g. `get_user`), not the importer's local alias. Use
|
||||
// the def's qualifiedName tail.
|
||||
const qn = ref.def.qualifiedName;
|
||||
if (qn === undefined) continue;
|
||||
const dot = qn.lastIndexOf('.');
|
||||
const sourceName = dot === -1 ? qn : qn.slice(dot + 1);
|
||||
|
||||
const sourceTypeRef = sourceModule.typeBindings.get(sourceName);
|
||||
if (sourceTypeRef === undefined) continue;
|
||||
|
||||
// Mirror the binding under the importer's local alias —
|
||||
// mutating typeBindings is safe because draftToScope produced
|
||||
// a non-frozen Map.
|
||||
(importerModule.typeBindings as Map<string, TypeRef>).set(localName, sourceTypeRef);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Re-follow chains across every scope so chains terminating in a
|
||||
// freshly-propagated import binding resolve to their terminal type.
|
||||
for (const parsed of parsedFiles) {
|
||||
for (const scope of parsed.scopes) {
|
||||
for (const [name, ref] of scope.typeBindings) {
|
||||
const resolved = followChainPostFinalize(ref, scope.id, indexes);
|
||||
if (resolved !== ref) {
|
||||
(scope.typeBindings as Map<string, TypeRef>).set(name, resolved);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -137,6 +137,83 @@ export function findCallableBindingInScope(
|
|||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk a scope chain upward looking for the innermost enclosing
|
||||
* Class scope and return that class's def. Used by per-language
|
||||
* `super` receiver branches to discover the dispatch base.
|
||||
*/
|
||||
export function findEnclosingClassDef(
|
||||
startScope: ScopeId,
|
||||
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;
|
||||
if (scope.kind === 'Class') {
|
||||
const cd = scope.ownedDefs.find((d) => d.type === 'Class');
|
||||
if (cd !== undefined) return cd;
|
||||
}
|
||||
currentId = scope.parent;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a free-function def by simple name across all parsed files,
|
||||
* preferring scope-chain-visible bindings (import + finalized scope
|
||||
* bindings) before falling back to a workspace-wide simple-name scan.
|
||||
*
|
||||
* The fallback scan is intentionally loose so per-language compound
|
||||
* resolvers can find a callable target even when the binding chain
|
||||
* doesn't surface it (e.g. cross-package re-exports the finalize
|
||||
* pass missed). Strictly-typed languages may want to disable the
|
||||
* fallback by simply not calling this helper from their compound
|
||||
* resolver.
|
||||
*/
|
||||
export function findExportedDefByName(
|
||||
name: string,
|
||||
inScope: ScopeId,
|
||||
scopes: ScopeResolutionIndexes,
|
||||
parsedFiles: readonly ParsedFile[],
|
||||
): SymbolDefinition | undefined {
|
||||
let currentId: ScopeId | null = inScope;
|
||||
const visited = new Set<ScopeId>();
|
||||
while (currentId !== null) {
|
||||
if (visited.has(currentId)) break;
|
||||
visited.add(currentId);
|
||||
const scope = scopes.scopeTree.getScope(currentId);
|
||||
if (scope === undefined) break;
|
||||
const local = scope.bindings.get(name);
|
||||
if (local !== undefined) {
|
||||
for (const b of local) {
|
||||
if (b.def.type === 'Function' || b.def.type === 'Method') return b.def;
|
||||
}
|
||||
}
|
||||
const finalized = scopes.bindings.get(currentId)?.get(name);
|
||||
if (finalized !== undefined) {
|
||||
for (const b of finalized) {
|
||||
if (b.def.type === 'Function' || b.def.type === 'Method') return b.def;
|
||||
}
|
||||
}
|
||||
currentId = scope.parent;
|
||||
}
|
||||
// Fallback: scan parsed files for any matching simple-name def.
|
||||
for (const f of parsedFiles) {
|
||||
for (const def of f.localDefs) {
|
||||
if (def.type !== 'Function' && def.type !== 'Method') continue;
|
||||
const qn = def.qualifiedName;
|
||||
if (qn === undefined) continue;
|
||||
const simple = qn.lastIndexOf('.') === -1 ? qn : qn.slice(qn.lastIndexOf('.') + 1);
|
||||
if (simple === name) return def;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a member of a class by simple name — a def whose `ownerId`
|
||||
* matches the class's nodeId and whose simple name matches `memberName`.
|
||||
|
|
|
|||
|
|
@ -52,15 +52,16 @@ import {
|
|||
buildGraphNodeLookup,
|
||||
buildPopulatedMethodDispatch,
|
||||
collectNamespaceTargets,
|
||||
emitFreeCallFallback,
|
||||
emitImportEdges,
|
||||
emitReferencesViaLookup,
|
||||
findCallableBindingInScope,
|
||||
findClassBindingInScope,
|
||||
findEnclosingClassDef,
|
||||
findExportedDef,
|
||||
findExportedDefByName,
|
||||
findOwnedMember,
|
||||
findReceiverTypeBinding,
|
||||
mapReferenceKindToEdgeType,
|
||||
resolveCallerGraphId,
|
||||
propagateImportedReturnTypes,
|
||||
resolveDefGraphId,
|
||||
tryEmitEdge,
|
||||
type GraphNodeLookup,
|
||||
|
|
@ -599,222 +600,6 @@ function emitReceiverBoundCalls(
|
|||
return emitted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit CALLS edges for free-call reference sites whose target is
|
||||
* imported (or otherwise visible only via post-finalize scope.bindings).
|
||||
*
|
||||
* The shared `MethodRegistry.lookup` only consults `scope.bindings`
|
||||
* (pre-finalize / local-only) for free calls. Cross-file imports land
|
||||
* in `indexes.bindings` (post-finalize). Without this fallback, every
|
||||
* `from x import f; f()` resolves to "unresolved".
|
||||
*
|
||||
* Same dual-source pattern as `findClassBindingInScope` — but accepts
|
||||
* Function/Method/Constructor instead of Class. Pre-seeds `seen` from
|
||||
* the shared resolver's emissions so we don't double-emit.
|
||||
*/
|
||||
function emitFreeCallFallback(
|
||||
graph: KnowledgeGraph,
|
||||
scopes: ScopeResolutionIndexes,
|
||||
parsedFiles: readonly ParsedFile[],
|
||||
nodeLookup: GraphNodeLookup,
|
||||
referenceIndex: { readonly bySourceScope: ReadonlyMap<ScopeId, readonly Reference[]> },
|
||||
handledSites: Set<string>,
|
||||
): number {
|
||||
let emitted = 0;
|
||||
const seen = new Set<string>();
|
||||
// Pre-seed `seen` with whatever the shared resolver + receiver-bound
|
||||
// pass already emitted so we never double-count an edge that another
|
||||
// path produced.
|
||||
for (const refs of referenceIndex.bySourceScope.values()) {
|
||||
for (const r of refs) {
|
||||
const targetDef = scopes.defs.get(r.toDef);
|
||||
if (targetDef === undefined) continue;
|
||||
const callerGraphId = resolveCallerGraphId(r.fromScope, scopes, nodeLookup);
|
||||
if (callerGraphId === undefined) continue;
|
||||
const tgtGraphId = resolveDefGraphId(targetDef.filePath, targetDef, nodeLookup);
|
||||
if (tgtGraphId === undefined) continue;
|
||||
const kind = mapReferenceKindToEdgeType(r.kind);
|
||||
if (kind === undefined) continue;
|
||||
seen.add(
|
||||
`${kind}:${callerGraphId}->${tgtGraphId}:${r.atRange.startLine}:${r.atRange.startCol}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (const parsed of parsedFiles) {
|
||||
for (const site of parsed.referenceSites) {
|
||||
if (site.kind !== 'call') continue;
|
||||
if (site.explicitReceiver !== undefined) continue;
|
||||
|
||||
const fnDef = findCallableBindingInScope(site.inScope, site.name, scopes);
|
||||
if (fnDef === undefined) continue;
|
||||
|
||||
// Free calls collapse to one CALLS edge per (caller, target)
|
||||
// pair. Multiple call sites in the same caller body should not
|
||||
// emit multiple edges (legacy DAG semantics — what
|
||||
// `default-params` / `variadic` / `overload` tests expect).
|
||||
// Member calls keep positional dedup elsewhere.
|
||||
const callerGraphId = resolveCallerGraphId(site.inScope, scopes, nodeLookup);
|
||||
if (callerGraphId === undefined) continue;
|
||||
const tgtGraphId = resolveDefGraphId(fnDef.filePath, fnDef, nodeLookup);
|
||||
if (tgtGraphId === undefined) continue;
|
||||
// Always mark the site as handled — even when the dedup-collapse
|
||||
// means we don't add a new edge — so `emit-references` skips its
|
||||
// potentially-wrong fallback for the same site.
|
||||
handledSites.add(`${parsed.filePath}:${site.atRange.startLine}:${site.atRange.startCol}`);
|
||||
const relId = `rel:CALLS:${callerGraphId}->${tgtGraphId}`;
|
||||
if (seen.has(relId)) continue;
|
||||
seen.add(relId);
|
||||
graph.addRelationship({
|
||||
id: relId,
|
||||
sourceId: callerGraphId,
|
||||
targetId: tgtGraphId,
|
||||
type: 'CALLS',
|
||||
confidence: 0.85,
|
||||
// Match legacy DAG's reason convention so consumers that
|
||||
// assert `reason === 'import-resolved'` keep working.
|
||||
reason: fnDef.filePath !== parsed.filePath ? 'import-resolved' : 'local-call',
|
||||
});
|
||||
emitted++;
|
||||
}
|
||||
}
|
||||
return emitted;
|
||||
}
|
||||
|
||||
/** Max chain depth for the post-finalize re-follow. */
|
||||
const RECHAIN_MAX_DEPTH = 8;
|
||||
|
||||
/** Walk `ref.rawName` through the scope chain's typeBindings looking
|
||||
* for a terminal class-like rawName. Mirrors the in-extractor
|
||||
* `followChainedRef` but operates on post-finalize Scope objects so
|
||||
* it can see imported return-types propagated by
|
||||
* `propagateImportedReturnTypes`. */
|
||||
function followChainPostFinalize(
|
||||
start: TypeRef,
|
||||
fromScopeId: ScopeId,
|
||||
scopes: ScopeResolutionIndexes,
|
||||
): TypeRef {
|
||||
let current = start;
|
||||
const visited = new Set<string>();
|
||||
for (let depth = 0; depth < RECHAIN_MAX_DEPTH; depth++) {
|
||||
if (current.rawName.includes('.')) return current;
|
||||
let scopeId: ScopeId | null = fromScopeId;
|
||||
let next: TypeRef | undefined;
|
||||
while (scopeId !== null) {
|
||||
const scope = scopes.scopeTree.getScope(scopeId);
|
||||
if (scope === undefined) break;
|
||||
next = scope.typeBindings.get(current.rawName);
|
||||
if (next !== undefined && next !== current) break;
|
||||
next = undefined;
|
||||
scopeId = scope.parent;
|
||||
}
|
||||
if (next === undefined) return current;
|
||||
if (visited.has(next.rawName)) return current;
|
||||
visited.add(next.rawName);
|
||||
current = next;
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy return-type typeBindings across module boundaries via import
|
||||
* bindings. For each module-scope import like `from x import f`, look
|
||||
* up `f` in the source file's module-scope typeBindings (which carries
|
||||
* `f → ReturnType` from the `@type-binding.return` capture) and mirror
|
||||
* that binding into the importer's module scope. Enables
|
||||
* `u = f(); u.save()` to chain through `f`'s return-type even when
|
||||
* `f` lives in another file.
|
||||
*
|
||||
* After propagation, re-runs the chain-follow on every scope's
|
||||
* typeBindings — pass-4 ran before propagation and missed any chain
|
||||
* whose terminal lived in a foreign file.
|
||||
*
|
||||
* Mutates `Scope.typeBindings` (a plain Map per `draftToScope`).
|
||||
*/
|
||||
function propagateImportedReturnTypes(
|
||||
parsedFiles: readonly ParsedFile[],
|
||||
indexes: ScopeResolutionIndexes,
|
||||
): void {
|
||||
// Index module scopes by filePath for fast cross-file lookup.
|
||||
const moduleScopeByFile = new Map<string, Scope>();
|
||||
for (const parsed of parsedFiles) {
|
||||
const moduleScope = parsed.scopes.find((s) => s.kind === 'Module');
|
||||
if (moduleScope !== undefined) moduleScopeByFile.set(parsed.filePath, moduleScope);
|
||||
}
|
||||
|
||||
for (const parsed of parsedFiles) {
|
||||
const importerModule = moduleScopeByFile.get(parsed.filePath);
|
||||
if (importerModule === undefined) continue;
|
||||
const finalizedBindings = indexes.bindings.get(importerModule.id);
|
||||
if (finalizedBindings === undefined) continue;
|
||||
|
||||
for (const [localName, refs] of finalizedBindings) {
|
||||
// Skip if importer already has a typeBinding for this name (e.g.
|
||||
// an explicit local annotation should win over import-derived).
|
||||
if (importerModule.typeBindings.has(localName)) continue;
|
||||
|
||||
for (const ref of refs) {
|
||||
if (ref.origin !== 'import' && ref.origin !== 'reexport') continue;
|
||||
const sourceModule = moduleScopeByFile.get(ref.def.filePath);
|
||||
if (sourceModule === undefined) continue;
|
||||
|
||||
// The source file's typeBinding is keyed by the def's simple
|
||||
// name (e.g. `get_user`), not the importer's local alias. Use
|
||||
// the def's qualifiedName tail.
|
||||
const qn = ref.def.qualifiedName;
|
||||
if (qn === undefined) continue;
|
||||
const dot = qn.lastIndexOf('.');
|
||||
const sourceName = dot === -1 ? qn : qn.slice(dot + 1);
|
||||
|
||||
const sourceTypeRef = sourceModule.typeBindings.get(sourceName);
|
||||
if (sourceTypeRef === undefined) continue;
|
||||
|
||||
// Mirror the binding under the importer's local alias —
|
||||
// mutating typeBindings is safe because draftToScope produced
|
||||
// a non-frozen Map.
|
||||
(importerModule.typeBindings as Map<string, TypeRef>).set(localName, sourceTypeRef);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Re-follow chains across every scope so chains terminating in a
|
||||
// freshly-propagated import binding resolve to their terminal type.
|
||||
for (const parsed of parsedFiles) {
|
||||
for (const scope of parsed.scopes) {
|
||||
for (const [name, ref] of scope.typeBindings) {
|
||||
const resolved = followChainPostFinalize(ref, scope.id, indexes);
|
||||
if (resolved !== ref) {
|
||||
(scope.typeBindings as Map<string, TypeRef>).set(name, resolved);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Walk a scope chain upward looking for the innermost enclosing
|
||||
* Class scope and return that class's def. Used by the `super()`
|
||||
* receiver case to discover the dispatch base. */
|
||||
function findEnclosingClassDef(
|
||||
startScope: ScopeId,
|
||||
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;
|
||||
if (scope.kind === 'Class') {
|
||||
const cd = scope.ownedDefs.find((d) => d.type === 'Class');
|
||||
if (cd !== undefined) return cd;
|
||||
}
|
||||
currentId = scope.parent;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** 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. */
|
||||
|
|
@ -964,50 +749,6 @@ function matchingOpenParen(text: string): number {
|
|||
return -1;
|
||||
}
|
||||
|
||||
/** Look up a free-function def by simple name across all parsed files
|
||||
* whose scope chain from `inScope` includes the binding. Used by the
|
||||
* free-call branch of `resolveCompoundReceiverClass`. */
|
||||
function findExportedDefByName(
|
||||
name: string,
|
||||
inScope: ScopeId,
|
||||
scopes: ScopeResolutionIndexes,
|
||||
parsedFiles: readonly ParsedFile[],
|
||||
): SymbolDefinition | undefined {
|
||||
// Walk the call site's scope chain looking for a binding.
|
||||
let currentId: ScopeId | null = inScope;
|
||||
const visited = new Set<ScopeId>();
|
||||
while (currentId !== null) {
|
||||
if (visited.has(currentId)) break;
|
||||
visited.add(currentId);
|
||||
const scope = scopes.scopeTree.getScope(currentId);
|
||||
if (scope === undefined) break;
|
||||
const local = scope.bindings.get(name);
|
||||
if (local !== undefined) {
|
||||
for (const b of local) {
|
||||
if (b.def.type === 'Function' || b.def.type === 'Method') return b.def;
|
||||
}
|
||||
}
|
||||
const finalized = scopes.bindings.get(currentId)?.get(name);
|
||||
if (finalized !== undefined) {
|
||||
for (const b of finalized) {
|
||||
if (b.def.type === 'Function' || b.def.type === 'Method') return b.def;
|
||||
}
|
||||
}
|
||||
currentId = scope.parent;
|
||||
}
|
||||
// Fallback: scan parsed files for any matching simple-name def.
|
||||
for (const f of parsedFiles) {
|
||||
for (const def of f.localDefs) {
|
||||
if (def.type !== 'Function' && def.type !== 'Method') continue;
|
||||
const qn = def.qualifiedName;
|
||||
if (qn === undefined) continue;
|
||||
const simple = qn.lastIndexOf('.') === -1 ? qn : qn.slice(qn.lastIndexOf('.') + 1);
|
||||
if (simple === name) return def;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Populate `ownerId` on Method/Function/Field defs that live structurally
|
||||
* inside a `Class` scope.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue