mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-20 00:11:37 +00:00
wire python scope-based resolution end-to-end (initial pass)
Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/c474dc66-5cf7-445d-8eb4-76501c5e6d67 Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>
This commit is contained in:
parent
f5862070cd
commit
f67061cd66
7 changed files with 935 additions and 1 deletions
|
|
@ -37,6 +37,7 @@ import { isLanguageAvailable, loadParser, loadLanguage } from '../tree-sitter/pa
|
|||
import { getProvider } from './languages/index.js';
|
||||
import { generateId } from '../../lib/utils.js';
|
||||
import { getLanguageFromFilename, SupportedLanguages } from 'gitnexus-shared';
|
||||
import { isRegistryPrimary } from './registry-primary-flag.js';
|
||||
import { isVerboseIngestionEnabled } from './utils/verbose.js';
|
||||
import { yieldToEventLoop } from './utils/event-loop.js';
|
||||
import {
|
||||
|
|
@ -750,6 +751,8 @@ export const processCalls = async (
|
|||
|
||||
const language = getLanguageFromFilename(file.path);
|
||||
if (!language) continue;
|
||||
// Registry-primary gate: scope-based phase owns CALLS for this lang.
|
||||
if (isRegistryPrimary(language)) continue;
|
||||
if (!isLanguageAvailable(language)) {
|
||||
if (skippedByLang) {
|
||||
skippedByLang.set(language, (skippedByLang.get(language) ?? 0) + 1);
|
||||
|
|
@ -2731,6 +2734,11 @@ export const processCallsFromExtracted = async (
|
|||
await yieldToEventLoop();
|
||||
}
|
||||
|
||||
// Registry-primary gate: skip Python (etc.) entirely when the
|
||||
// scope-based phase owns CALLS for this language.
|
||||
const fileLanguage = getLanguageFromFilename(filePath);
|
||||
if (fileLanguage && isRegistryPrimary(fileLanguage)) continue;
|
||||
|
||||
ctx.enableCache(filePath);
|
||||
const widenCache: WidenCache = new Map();
|
||||
const receiverMap = fileReceiverTypes.get(filePath);
|
||||
|
|
|
|||
|
|
@ -5,7 +5,8 @@ import { isLanguageAvailable, loadParser, loadLanguage } from '../tree-sitter/pa
|
|||
import { getProvider, getProviderForFile, providersWithImplicitWiring } from './languages/index.js';
|
||||
import type { LanguageProvider } from './language-provider.js';
|
||||
import { generateId } from '../../lib/utils.js';
|
||||
import { getLanguageFromFilename } from 'gitnexus-shared';
|
||||
import { getLanguageFromFilename, SupportedLanguages } from 'gitnexus-shared';
|
||||
import { isRegistryPrimary } from './registry-primary-flag.js';
|
||||
import { isVerboseIngestionEnabled } from './utils/verbose.js';
|
||||
import { yieldToEventLoop } from './utils/event-loop.js';
|
||||
import type { ExtractedImport } from './workers/parse-worker.js';
|
||||
|
|
@ -42,6 +43,8 @@ function wireImplicitImports(
|
|||
|
||||
const grouped = new Map<LanguageProvider, string[]>();
|
||||
for (const file of files) {
|
||||
const lang = getLanguageFromFilename(file);
|
||||
if (lang && isRegistryPrimary(lang)) continue;
|
||||
const provider = getProviderForFile(file);
|
||||
if (!provider?.implicitImportWirer) continue;
|
||||
let list = grouped.get(provider);
|
||||
|
|
@ -282,6 +285,11 @@ export const processImports = async (
|
|||
// 1. Check language support first
|
||||
const language = getLanguageFromFilename(file.path);
|
||||
if (!language) continue;
|
||||
// Registry-primary gate: when REGISTRY_PRIMARY_<LANG>=1, the
|
||||
// scope-based pipeline phase (`pythonScopePhase`, etc.) owns
|
||||
// IMPORTS/CALLS emission for this language. Skip the legacy path
|
||||
// here so we don't double-emit edges.
|
||||
if (isRegistryPrimary(language)) continue;
|
||||
if (!isLanguageAvailable(language)) {
|
||||
if (skippedByLang) {
|
||||
skippedByLang.set(language, (skippedByLang.get(language) ?? 0) + 1);
|
||||
|
|
@ -467,6 +475,10 @@ export const processImportsFromExtracted = async (
|
|||
}
|
||||
|
||||
for (const imp of fileImports) {
|
||||
// Registry-primary gate: skip when the scope-based phase owns
|
||||
// emission for this language. `imp.language` is set by the parse
|
||||
// worker; trust it here.
|
||||
if (isRegistryPrimary(imp.language)) continue;
|
||||
totalImportsFound++;
|
||||
|
||||
const provider = getProvider(imp.language);
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ export { routesPhase, type RoutesOutput, type RouteEntry } from './routes.js';
|
|||
export { toolsPhase, type ToolsOutput, type ToolDef } from './tools.js';
|
||||
export { ormPhase, type ORMOutput } from './orm.js';
|
||||
export { crossFilePhase, type CrossFileOutput } from './cross-file.js';
|
||||
export { pythonScopePhase, type PythonScopeOutput } from './python-scope.js';
|
||||
export { mroPhase, type MROOutput } from './mro.js';
|
||||
export { communitiesPhase, type CommunitiesOutput } from './communities.js';
|
||||
export { processesPhase, type ProcessesOutput } from './processes.js';
|
||||
|
|
|
|||
113
gitnexus/src/core/ingestion/pipeline-phases/python-scope.ts
Normal file
113
gitnexus/src/core/ingestion/pipeline-phases/python-scope.ts
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
/**
|
||||
* Phase: pythonScope
|
||||
*
|
||||
* Registry-primary resolution for Python files (RFC #909 Ring 4).
|
||||
*
|
||||
* Gated by `REGISTRY_PRIMARY_PYTHON=1` (via `isRegistryPrimary`). When
|
||||
* the flag is OFF (default), this phase is a no-op: the legacy paths in
|
||||
* `import-processor.ts` and `call-processor.ts` continue to handle
|
||||
* Python imports + calls and this phase contributes nothing to the
|
||||
* graph.
|
||||
*
|
||||
* When the flag is ON, this phase:
|
||||
* 1. Reads every `.py` file in the workspace.
|
||||
* 2. Drives the scope-based pipeline end-to-end (extract → finalize →
|
||||
* resolve → emit) via `runPythonScopeResolution`.
|
||||
* 3. Emits IMPORTS / CALLS / ACCESSES / INHERITS / USES edges directly.
|
||||
*
|
||||
* Pairs with the matching gates in `import-processor.ts` and
|
||||
* `call-processor.ts` that skip Python files when this phase is active —
|
||||
* so we don't double-emit edges from both code paths.
|
||||
*
|
||||
* @deps parse (needs Symbol nodes already in the graph so emit-references
|
||||
* can attach edges to existing Function/Method/Class nodes)
|
||||
* @reads scannedFiles
|
||||
* @writes graph (IMPORTS, CALLS, ACCESSES, INHERITS, USES)
|
||||
*/
|
||||
|
||||
import type { PipelinePhase, PipelineContext, PhaseResult } from './types.js';
|
||||
import { getPhaseOutput } from './types.js';
|
||||
import type { StructureOutput } from './structure.js';
|
||||
import { isRegistryPrimary } from '../registry-primary-flag.js';
|
||||
import { SupportedLanguages, getLanguageFromFilename } from 'gitnexus-shared';
|
||||
import { readFileContents } from '../filesystem-walker.js';
|
||||
import { runPythonScopeResolution } from '../python-scope-emit.js';
|
||||
import { isDev } from '../utils/env.js';
|
||||
|
||||
export interface PythonScopeOutput {
|
||||
/** True when the flag was on and the phase actually ran. */
|
||||
readonly ran: boolean;
|
||||
/** Python files seen by the phase. `0` when `ran === false`. */
|
||||
readonly filesProcessed: number;
|
||||
/** IMPORTS edges emitted by this phase. */
|
||||
readonly importsEmitted: number;
|
||||
/** Reference (CALLS/ACCESSES/INHERITS/USES) edges emitted. */
|
||||
readonly referenceEdgesEmitted: number;
|
||||
}
|
||||
|
||||
const NOOP_OUTPUT: PythonScopeOutput = Object.freeze({
|
||||
ran: false,
|
||||
filesProcessed: 0,
|
||||
importsEmitted: 0,
|
||||
referenceEdgesEmitted: 0,
|
||||
});
|
||||
|
||||
export const pythonScopePhase: PipelinePhase<PythonScopeOutput> = {
|
||||
name: 'pythonScope',
|
||||
// Depends on `parse` because emit-references attaches edges to
|
||||
// already-existing Symbol nodes (Function/Method/Class). The legacy
|
||||
// `parse` phase still creates those nodes; we only replace the
|
||||
// import + call resolution layer.
|
||||
deps: ['parse', 'structure'],
|
||||
|
||||
async execute(
|
||||
ctx: PipelineContext,
|
||||
deps: ReadonlyMap<string, PhaseResult<unknown>>,
|
||||
): Promise<PythonScopeOutput> {
|
||||
if (!isRegistryPrimary(SupportedLanguages.Python)) {
|
||||
return NOOP_OUTPUT;
|
||||
}
|
||||
|
||||
const { scannedFiles } = getPhaseOutput<StructureOutput>(deps, 'structure');
|
||||
|
||||
// Only `.py` files; the per-language flag scopes by extension via
|
||||
// `getLanguageFromFilename`.
|
||||
const pythonScanned = scannedFiles.filter(
|
||||
(f) => getLanguageFromFilename(f.path) === SupportedLanguages.Python,
|
||||
);
|
||||
if (pythonScanned.length === 0) return NOOP_OUTPUT;
|
||||
|
||||
// Read source for every Python file. `runPythonScopeResolution`
|
||||
// re-parses each file via `pythonProvider.emitScopeCaptures`; we
|
||||
// accept the duplicate parse cost in this first cut and can revisit
|
||||
// by sharing `astCache` across phases if needed.
|
||||
const filePaths = pythonScanned.map((f) => f.path);
|
||||
const contents = await readFileContents(ctx.repoPath, filePaths);
|
||||
const files: { path: string; content: string }[] = [];
|
||||
for (const fp of filePaths) {
|
||||
const content = contents.get(fp);
|
||||
if (content !== undefined) files.push({ path: fp, content });
|
||||
}
|
||||
|
||||
const stats = runPythonScopeResolution({
|
||||
graph: ctx.graph,
|
||||
files,
|
||||
onWarn: (msg) => {
|
||||
if (isDev) console.warn(`[python-scope] ${msg}`);
|
||||
},
|
||||
});
|
||||
|
||||
if (isDev) {
|
||||
console.log(
|
||||
`🐍 python-scope: ${stats.filesProcessed} files → ${stats.importsEmitted} IMPORTS + ${stats.referenceEdgesEmitted} reference edges (${stats.resolve.unresolved} unresolved sites, ${stats.referenceSkipped} skipped)`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
ran: true,
|
||||
filesProcessed: stats.filesProcessed,
|
||||
importsEmitted: stats.importsEmitted,
|
||||
referenceEdgesEmitted: stats.referenceEdgesEmitted,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
|
@ -30,6 +30,7 @@ import {
|
|||
toolsPhase,
|
||||
ormPhase,
|
||||
crossFilePhase,
|
||||
pythonScopePhase,
|
||||
mroPhase,
|
||||
communitiesPhase,
|
||||
processesPhase,
|
||||
|
|
@ -80,6 +81,7 @@ function buildPhaseList(options?: PipelineOptions): PipelinePhase[] {
|
|||
toolsPhase,
|
||||
ormPhase,
|
||||
crossFilePhase,
|
||||
pythonScopePhase,
|
||||
];
|
||||
|
||||
if (!options?.skipGraphPhases) {
|
||||
|
|
|
|||
567
gitnexus/src/core/ingestion/python-scope-emit.ts
Normal file
567
gitnexus/src/core/ingestion/python-scope-emit.ts
Normal file
|
|
@ -0,0 +1,567 @@
|
|||
/**
|
||||
* `runPythonScopeResolution` — drive the registry-primary resolution
|
||||
* pipeline end-to-end for the Python files in a workspace and emit
|
||||
* graph edges (RFC #909 Ring 4 — Python migration).
|
||||
*
|
||||
* ParsedFile[] (one per .py via `extractParsedFile`)
|
||||
* │ finalizeScopeModel( + Python hooks adapted to FinalizeHooks)
|
||||
* ▼
|
||||
* ScopeResolutionIndexes
|
||||
* │ resolveReferenceSites
|
||||
* ▼
|
||||
* ReferenceIndex
|
||||
* │ emitReferencesToGraph (CALLS / ACCESSES / INHERITS / USES)
|
||||
* │ + emitImportEdgesToGraph (file→file IMPORTS)
|
||||
* ▼
|
||||
* KnowledgeGraph
|
||||
*
|
||||
* The orchestrator is the public seam between the gitnexus pipeline and
|
||||
* the language-agnostic scope-resolution machinery in `gitnexus-shared`.
|
||||
* It wires the Python provider's hooks into `FinalizeOrchestratorOptions`
|
||||
* and threads the workspace index through the import-target resolver.
|
||||
*
|
||||
* Gating lives in the pipeline phase (`pipeline-phases/python-scope.ts`),
|
||||
* not here — this function is "what to do" once we've decided to do it.
|
||||
*/
|
||||
|
||||
import type {
|
||||
BindingRef,
|
||||
ImportEdge,
|
||||
NodeLabel,
|
||||
ParsedFile,
|
||||
Reference,
|
||||
RegistryProviders,
|
||||
Scope,
|
||||
ScopeId,
|
||||
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 {
|
||||
pythonArityCompatibility,
|
||||
pythonMergeBindings,
|
||||
resolvePythonImportTarget,
|
||||
type PythonResolveContext,
|
||||
} from './languages/python/index.js';
|
||||
import { generateId } from '../../lib/utils.js';
|
||||
|
||||
// ─── Public API ─────────────────────────────────────────────────────────────
|
||||
|
||||
export interface RunPythonScopeResolutionInput {
|
||||
readonly graph: KnowledgeGraph;
|
||||
readonly files: readonly { readonly path: string; readonly content: string }[];
|
||||
/** Optional warning sink (e.g. for telemetry). Failures per-file are non-fatal. */
|
||||
readonly onWarn?: (message: string) => void;
|
||||
}
|
||||
|
||||
export interface RunPythonScopeResolutionStats {
|
||||
readonly filesProcessed: number;
|
||||
readonly filesSkipped: number;
|
||||
readonly importsEmitted: number;
|
||||
readonly resolve: ResolveStats;
|
||||
readonly referenceEdgesEmitted: number;
|
||||
readonly referenceSkipped: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the full registry-primary resolution path for `files` and emit the
|
||||
* resulting CALLS / ACCESSES / INHERITS / USES / IMPORTS edges into
|
||||
* `graph`. Caller is responsible for ensuring `files` are Python only.
|
||||
*
|
||||
* Returns telemetry; never throws on per-file failures (warnings flow
|
||||
* through `onWarn`).
|
||||
*/
|
||||
export function runPythonScopeResolution(
|
||||
input: RunPythonScopeResolutionInput,
|
||||
): RunPythonScopeResolutionStats {
|
||||
const { graph, files } = input;
|
||||
const onWarn = input.onWarn ?? (() => {});
|
||||
|
||||
// ── Phase 1: extract each file → ParsedFile ─────────────────────────────
|
||||
const parsedFiles: ParsedFile[] = [];
|
||||
let filesSkipped = 0;
|
||||
for (const file of files) {
|
||||
const parsed = extractParsedFile(pythonProvider, file.content, file.path, onWarn);
|
||||
if (parsed === undefined) {
|
||||
filesSkipped++;
|
||||
continue;
|
||||
}
|
||||
populateMethodOwnerIds(parsed);
|
||||
parsedFiles.push(parsed);
|
||||
}
|
||||
|
||||
if (parsedFiles.length === 0) {
|
||||
return {
|
||||
filesProcessed: 0,
|
||||
filesSkipped,
|
||||
importsEmitted: 0,
|
||||
resolve: { sitesProcessed: 0, referencesEmitted: 0, unresolved: 0 },
|
||||
referenceEdgesEmitted: 0,
|
||||
referenceSkipped: 0,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Phase 2: finalize → ScopeResolutionIndexes ─────────────────────────
|
||||
const allFilePaths = new Set(parsedFiles.map((f) => f.filePath));
|
||||
// Pre-build a graph-node lookup (used both for MRO bridging and for
|
||||
// edge emission below). EXTENDS edges already in the graph (from the
|
||||
// legacy heritage processor in `parse`) drive the MRO chain — we
|
||||
// mirror them into a `MethodDispatchIndex` so receiver-typed
|
||||
// resolution can walk inherited methods.
|
||||
const nodeLookup = buildGraphNodeLookup(graph);
|
||||
const mroByClassDefId = buildPythonMro(graph, parsedFiles, nodeLookup);
|
||||
|
||||
const indexes = finalizeScopeModel(parsedFiles, {
|
||||
hooks: {
|
||||
// Adapter: shared `finalize()` calls `resolveImportTarget(targetRaw,
|
||||
// fromFile, ws)` with `targetRaw` already extracted; the Python
|
||||
// provider's signature takes a synthetic `ParsedImport`. Wrap it so
|
||||
// the hook contract is satisfied without leaking provider internals.
|
||||
resolveImportTarget: (targetRaw, fromFile) => {
|
||||
const ws: PythonResolveContext = { fromFile, allFilePaths };
|
||||
return resolvePythonImportTarget(
|
||||
{ kind: 'named', localName: '_', importedName: '_', targetRaw },
|
||||
ws as unknown as WorkspaceIndex,
|
||||
);
|
||||
},
|
||||
// Python LEGB precedence: local > import/namespace/reexport > wildcard.
|
||||
mergeBindings: (existing, incoming, scopeId) => {
|
||||
// `pythonMergeBindings(scope, bindings)` only consults
|
||||
// `BindingRef.origin` for tier ordering, not `scope.kind`. A
|
||||
// shape-stub satisfies the type contract without falsifying
|
||||
// behavior.
|
||||
const fakeScope = { id: scopeId } as unknown as Scope;
|
||||
return pythonMergeBindings(fakeScope, [...existing, ...incoming]);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Stitch the MRO into the finalized indexes. `finalizeScopeModel`
|
||||
// builds an empty MethodDispatchIndex (the comment in
|
||||
// `finalize-orchestrator.ts:124-129` notes this is a known gap); we
|
||||
// overwrite with a populated index that wraps the same shape.
|
||||
(indexes as { methodDispatch: typeof indexes.methodDispatch }).methodDispatch =
|
||||
buildPopulatedMethodDispatch(mroByClassDefId);
|
||||
|
||||
// ── Phase 3: resolve references via Registry.lookup ─────────────────────
|
||||
const providers: RegistryProviders = {
|
||||
// The Python provider's `arityCompatibility` predates the
|
||||
// RegistryProviders contract and uses `(def, callsite)` argument
|
||||
// order. The contract is `(callsite, def)`. Adapt at the boundary
|
||||
// so the provider source stays untouched.
|
||||
arityCompatibility: (callsite, def) => pythonArityCompatibility(def, callsite),
|
||||
};
|
||||
const { referenceIndex, stats: resolveStats } = resolveReferenceSites({
|
||||
scopes: indexes,
|
||||
providers,
|
||||
});
|
||||
|
||||
// ── Phase 4: emit graph edges ───────────────────────────────────────────
|
||||
// The shared `emit-references.ts` emits edges between
|
||||
// `SymbolDefinition.nodeId` values, which use the scope-extractor's
|
||||
// `def:<file>#<line>:<col>:<type>:<name>` format. The CLI's existing
|
||||
// graph nodes (created by `parsing-processor.ts`) use the legacy
|
||||
// `<Type>:<file>:<qualifiedName>` ID format. Bridging is required so
|
||||
// edges actually link to existing graph nodes.
|
||||
//
|
||||
// We do that bridging here: translate the resolved `Reference`
|
||||
// records' source + target via `nodeLookup` (built earlier alongside
|
||||
// the MRO map) before calling `graph.addRelationship`. This keeps
|
||||
// `emit-references.ts` untouched (it stays pure scope-resolution).
|
||||
const { emitted, skipped } = emitReferencesViaLookup(
|
||||
graph,
|
||||
indexes,
|
||||
referenceIndex,
|
||||
nodeLookup,
|
||||
);
|
||||
|
||||
// IMPORTS edges: mirror the legacy file-to-file shape so existing
|
||||
// queries that aggregate by `File → File` continue to work. Done here
|
||||
// (not in `emit-references.emitScopeGraph`) because that path emits
|
||||
// scope-to-scope edges, which are a different schema. Keeping the
|
||||
// legacy shape avoids churn in downstream consumers and tests.
|
||||
const importsEmitted = emitImportEdges(graph, indexes.imports, indexes.scopeTree);
|
||||
|
||||
return {
|
||||
filesProcessed: parsedFiles.length,
|
||||
filesSkipped,
|
||||
importsEmitted,
|
||||
resolve: resolveStats,
|
||||
referenceEdgesEmitted: emitted,
|
||||
referenceSkipped: skipped,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Internal ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Build a Python MRO map keyed by scope-resolution Class `DefId`.
|
||||
*
|
||||
* The legacy `parse` phase has already emitted EXTENDS edges into the
|
||||
* graph (via the heritage processor in `parsing-processor.ts`) by the
|
||||
* time this orchestrator runs (we depend on `parse`). We mirror those
|
||||
* edges into a `DefId → ancestor DefId[]` map so receiver-typed
|
||||
* `MethodRegistry.lookup` can walk inherited methods.
|
||||
*
|
||||
* MRO ordering: this is a **simple linear walk** (depth-first parent
|
||||
* chain, dedup by first-seen). Full Python C3 linearization lives in
|
||||
* the legacy heritage processor; replicating it here is out of scope
|
||||
* for the first cut. The single-inheritance case — which covers the
|
||||
* existing fixture suite (`User → BaseModel`, `Child → Parent`,
|
||||
* `Grandchild → Child → Parent`) — is identical to C3, so the
|
||||
* difference only surfaces with diamond hierarchies. Tracked as a
|
||||
* follow-up alongside generalizing this orchestrator across languages.
|
||||
*/
|
||||
function buildPythonMro(
|
||||
graph: KnowledgeGraph,
|
||||
parsedFiles: readonly ParsedFile[],
|
||||
nodeLookup: GraphNodeLookup,
|
||||
): Map<string /* DefId */, string[] /* DefId[] */> {
|
||||
// Step 1: build (graph node id) → (parent graph node id[]) from
|
||||
// EXTENDS edges. Python only has class inheritance via `class
|
||||
// Child(Parent)`, which the heritage processor maps to EXTENDS
|
||||
// (not IMPLEMENTS).
|
||||
const parentsByGraphId = new Map<string, string[]>();
|
||||
for (const rel of graph.iterRelationships()) {
|
||||
if (rel.type !== 'EXTENDS') continue;
|
||||
let list = parentsByGraphId.get(rel.sourceId);
|
||||
if (list === undefined) {
|
||||
list = [];
|
||||
parentsByGraphId.set(rel.sourceId, list);
|
||||
}
|
||||
list.push(rel.targetId);
|
||||
}
|
||||
|
||||
// Step 2: collect every Class def from the parsed scope model and
|
||||
// build a graph-node → DefId reverse map.
|
||||
const defIdByGraphId = new Map<string, string>();
|
||||
for (const parsed of parsedFiles) {
|
||||
for (const def of parsed.localDefs) {
|
||||
if (def.type !== 'Class') continue;
|
||||
const graphId = resolveDefGraphId(parsed.filePath, def, nodeLookup);
|
||||
if (graphId !== undefined) defIdByGraphId.set(graphId, def.nodeId);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: for each Class def, walk parents transitively (depth-first,
|
||||
// first-seen-wins) and translate each ancestor back to its DefId.
|
||||
const mroByDefId = new Map<string, string[]>();
|
||||
for (const [graphId, defId] of defIdByGraphId) {
|
||||
const ancestors: string[] = [];
|
||||
const visited = new Set<string>();
|
||||
const queue: string[] = [...(parentsByGraphId.get(graphId) ?? [])];
|
||||
while (queue.length > 0) {
|
||||
const cur = queue.shift()!;
|
||||
if (visited.has(cur)) continue;
|
||||
visited.add(cur);
|
||||
const ancDefId = defIdByGraphId.get(cur);
|
||||
if (ancDefId !== undefined) ancestors.push(ancDefId);
|
||||
for (const p of parentsByGraphId.get(cur) ?? []) queue.push(p);
|
||||
}
|
||||
mroByDefId.set(defId, ancestors);
|
||||
}
|
||||
return mroByDefId;
|
||||
}
|
||||
|
||||
const EMPTY_DEFS: readonly string[] = Object.freeze([]);
|
||||
|
||||
/** Wrap a `DefId → ancestor DefId[]` map in the `MethodDispatchIndex` shape. */
|
||||
function buildPopulatedMethodDispatch(
|
||||
mroByDefId: ReadonlyMap<string, readonly string[]>,
|
||||
): import('gitnexus-shared').MethodDispatchIndex {
|
||||
return {
|
||||
mroByOwnerDefId: mroByDefId,
|
||||
implsByInterfaceDefId: new Map(),
|
||||
mroFor(ownerDefId) {
|
||||
return mroByDefId.get(ownerDefId) ?? EMPTY_DEFS;
|
||||
},
|
||||
implementorsOf() {
|
||||
return EMPTY_DEFS;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a `(filePath, simpleName, kind) → graphNodeId` lookup over the
|
||||
* graph's Function/Method/Class/Constructor nodes. Used to translate
|
||||
* scope-resolution `SymbolDefinition.nodeId` values into the legacy
|
||||
* graph node ID format that downstream consumers (queries, edges, MCP)
|
||||
* expect.
|
||||
*/
|
||||
type GraphNodeLookup = ReadonlyMap<string, string>;
|
||||
|
||||
function buildGraphNodeLookup(graph: KnowledgeGraph): GraphNodeLookup {
|
||||
const lookup = new Map<string, string>();
|
||||
for (const node of graph.iterNodes()) {
|
||||
const props = node.properties as { filePath?: string; name?: string };
|
||||
if (props.filePath === undefined || props.name === undefined) continue;
|
||||
if (!isLinkableLabel(node.label)) continue;
|
||||
// Keyed by (filePath, simpleName). Class kinds and method kinds
|
||||
// share the same simple-name space within a file in Python — no
|
||||
// overload of "class Foo" + "def Foo()" at the same level — so a
|
||||
// single key per (file, name) is unambiguous in practice. The
|
||||
// method-vs-class disambiguation for resolved references happens
|
||||
// earlier inside `MethodRegistry.lookup` (Step 1 + Step 2).
|
||||
const key = `${props.filePath}::${props.name}`;
|
||||
if (!lookup.has(key)) lookup.set(key, node.id);
|
||||
}
|
||||
return lookup;
|
||||
}
|
||||
|
||||
function isLinkableLabel(label: NodeLabel): boolean {
|
||||
return (
|
||||
label === 'Function' ||
|
||||
label === 'Method' ||
|
||||
label === 'Constructor' ||
|
||||
label === 'Class' ||
|
||||
label === 'Interface' ||
|
||||
label === 'Struct' ||
|
||||
label === 'Enum'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate the resolved `ReferenceIndex` into legacy graph edges.
|
||||
*
|
||||
* Per reference:
|
||||
* 1. Resolve `fromScope` → caller graph-node id by walking the scope
|
||||
* chain looking for an enclosing Function/Method/Class.
|
||||
* 2. Resolve `toDef` → target graph-node id via `nodeLookup`.
|
||||
* 3. Emit the edge (`CALLS` / `READS` / `WRITES` / `EXTENDS` / `USES`)
|
||||
* with the standard reason format.
|
||||
*
|
||||
* Skips (without throwing) when either side fails to map — either side
|
||||
* may legitimately not exist as a graph node (e.g., a resolved target
|
||||
* lives in an external file that wasn't ingested into the graph).
|
||||
*/
|
||||
function emitReferencesViaLookup(
|
||||
graph: KnowledgeGraph,
|
||||
scopes: ScopeResolutionIndexes,
|
||||
referenceIndex: { readonly bySourceScope: ReadonlyMap<ScopeId, readonly Reference[]> },
|
||||
nodeLookup: GraphNodeLookup,
|
||||
): { emitted: number; skipped: number } {
|
||||
let emitted = 0;
|
||||
let skipped = 0;
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const [fromScope, refs] of referenceIndex.bySourceScope) {
|
||||
const callerGraphId = resolveCallerGraphId(fromScope, scopes, nodeLookup);
|
||||
if (callerGraphId === undefined) {
|
||||
skipped += refs.length;
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const ref of refs) {
|
||||
const targetDef = scopes.defs.get(ref.toDef);
|
||||
if (targetDef === undefined) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
const targetGraphId = resolveDefGraphId(targetDef.filePath, targetDef, nodeLookup);
|
||||
if (targetGraphId === undefined) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const edgeType = mapReferenceKindToEdgeType(ref.kind);
|
||||
if (edgeType === undefined) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const dedupKey = `${edgeType}:${callerGraphId}->${targetGraphId}:${ref.atRange.startLine}:${ref.atRange.startCol}`;
|
||||
if (seen.has(dedupKey)) continue;
|
||||
seen.add(dedupKey);
|
||||
|
||||
graph.addRelationship({
|
||||
id: `rel:${dedupKey}`,
|
||||
sourceId: callerGraphId,
|
||||
targetId: targetGraphId,
|
||||
type: edgeType,
|
||||
confidence: ref.confidence,
|
||||
reason: `python-scope: ${ref.kind}`,
|
||||
});
|
||||
emitted++;
|
||||
}
|
||||
}
|
||||
return { emitted, skipped };
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk the scope chain from `startScope` upward looking for the first
|
||||
* scope whose `ownedDefs` contains a Function/Method/Class — that's
|
||||
* our caller anchor. Translate via `nodeLookup` to the graph-node ID.
|
||||
*/
|
||||
function resolveCallerGraphId(
|
||||
startScope: ScopeId,
|
||||
scopes: ScopeResolutionIndexes,
|
||||
nodeLookup: GraphNodeLookup,
|
||||
): string | undefined {
|
||||
let current: ScopeId | null = startScope;
|
||||
const visited = new Set<ScopeId>();
|
||||
while (current !== null) {
|
||||
if (visited.has(current)) return undefined;
|
||||
visited.add(current);
|
||||
const scope = scopes.scopeTree.getScope(current);
|
||||
if (scope === undefined) return undefined;
|
||||
|
||||
// Prefer Function/Method anchors; fall back to Class.
|
||||
const fnDef = scope.ownedDefs.find(
|
||||
(d) => d.type === 'Function' || d.type === 'Method' || d.type === 'Constructor',
|
||||
);
|
||||
if (fnDef !== undefined) {
|
||||
const id = resolveDefGraphId(scope.filePath, fnDef, nodeLookup);
|
||||
if (id !== undefined) return id;
|
||||
}
|
||||
const classDef = scope.ownedDefs.find((d) => isLinkableLabel(d.type));
|
||||
if (classDef !== undefined) {
|
||||
const id = resolveDefGraphId(scope.filePath, classDef, nodeLookup);
|
||||
if (id !== undefined) return id;
|
||||
}
|
||||
current = scope.parent;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Look up a `SymbolDefinition` in the graph node lookup by file+name. */
|
||||
function resolveDefGraphId(
|
||||
filePath: string,
|
||||
def: { qualifiedName?: string },
|
||||
nodeLookup: GraphNodeLookup,
|
||||
): string | undefined {
|
||||
const qn = def.qualifiedName;
|
||||
if (qn === undefined || qn.length === 0) return undefined;
|
||||
const simpleName = qn.lastIndexOf('.') === -1 ? qn : qn.slice(qn.lastIndexOf('.') + 1);
|
||||
return nodeLookup.get(`${filePath}::${simpleName}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a `Reference.kind` to a graph edge type. `import-use` is dropped
|
||||
* (no edge type today — provenance lives on the IMPORTS edge already
|
||||
* emitted by `emitImportEdges`).
|
||||
*/
|
||||
function mapReferenceKindToEdgeType(
|
||||
kind: Reference['kind'],
|
||||
): 'CALLS' | 'ACCESSES' | 'EXTENDS' | 'USES' | undefined {
|
||||
switch (kind) {
|
||||
case 'call':
|
||||
return 'CALLS';
|
||||
case 'read':
|
||||
case 'write':
|
||||
return 'ACCESSES';
|
||||
case 'inherits':
|
||||
return 'EXTENDS';
|
||||
case 'type-reference':
|
||||
return 'USES';
|
||||
case 'import-use':
|
||||
return undefined;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Populate `ownerId` on Method/Function/Field defs that live structurally
|
||||
* inside a `Class` scope.
|
||||
*
|
||||
* The scope extractor explicitly does NOT set `ownerId` (see
|
||||
* `scope-extractor.ts:449-457`); the design comment defers it to a
|
||||
* "finalize follow-up pass that sees every def already in place." That
|
||||
* pass doesn't exist yet centrally — building it generically here would
|
||||
* require knowing per-language ownership rules (Python: methods belong
|
||||
* to the lexically enclosing class; Ruby: explicit `class << self`;
|
||||
* etc.).
|
||||
*
|
||||
* Python's rule is simple — direct lexical containment — so we apply it
|
||||
* locally before finalize runs. Without this, `MethodRegistry.lookup`
|
||||
* Step 2 (`collectOwnedMembers`) returns no candidates because no def
|
||||
* has the receiver class as its owner, and receiver-typed calls like
|
||||
* `model.validate()` resolve to nothing.
|
||||
*
|
||||
* Mutates `parsed.localDefs` in place via type cast — `SymbolDefinition`
|
||||
* is `readonly` for consumers but the extractor returns plain objects.
|
||||
* Defs are shared by reference between `localDefs` and `Scope.ownedDefs`,
|
||||
* so this single mutation is visible from both sides.
|
||||
*/
|
||||
function populateMethodOwnerIds(parsed: ParsedFile): void {
|
||||
// Build a `(parent scope id) → (class def in that parent's chain)` map.
|
||||
// Python scope topology (per the extractor):
|
||||
// Module
|
||||
// └─ Class scope ← `ownedDefs: [Class def]`
|
||||
// └─ Function scope ← `ownedDefs: [Function def]`
|
||||
// So a method's `ownerId` is the Class def owned by its **parent**
|
||||
// scope (when that parent is a Class scope).
|
||||
const scopesById = new Map<ScopeId, Scope>();
|
||||
for (const scope of parsed.scopes) scopesById.set(scope.id, scope);
|
||||
|
||||
for (const scope of parsed.scopes) {
|
||||
if (scope.parent === null) continue;
|
||||
const parentScope = scopesById.get(scope.parent);
|
||||
if (parentScope === undefined || parentScope.kind !== 'Class') continue;
|
||||
|
||||
// The parent Class scope owns the Class def itself. Pick the first
|
||||
// class-kind def in that scope (Python only has one class-def per
|
||||
// class scope).
|
||||
const classDef = parentScope.ownedDefs.find((d) => d.type === 'Class');
|
||||
if (classDef === undefined) continue;
|
||||
|
||||
// Mutate `ownerId` in place on every def owned by this scope. Defs
|
||||
// are referenced from both `parsed.localDefs` and `Scope.ownedDefs`
|
||||
// — one write covers both.
|
||||
for (const def of scope.ownedDefs) {
|
||||
(def as { ownerId?: string }).ownerId = classDef.nodeId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit one File→File IMPORTS edge per linked `ImportEdge`. Deduplicates
|
||||
* by `(sourceFile, targetFile)` so multi-symbol imports from the same
|
||||
* module collapse to a single edge — matching the legacy schema.
|
||||
*/
|
||||
function emitImportEdges(
|
||||
graph: KnowledgeGraph,
|
||||
imports: ReadonlyMap<ScopeId, readonly ImportEdge[]>,
|
||||
scopeTree: ReturnType<typeof finalizeScopeModel>['scopeTree'],
|
||||
): number {
|
||||
const seen = new Set<string>();
|
||||
let emitted = 0;
|
||||
|
||||
for (const [scopeId, edges] of imports) {
|
||||
const scope = scopeTree.getScope(scopeId);
|
||||
if (scope === undefined) continue;
|
||||
const sourceFile = scope.filePath;
|
||||
|
||||
for (const edge of edges) {
|
||||
if (edge.targetFile === null) continue;
|
||||
if (edge.targetFile === sourceFile) continue;
|
||||
|
||||
const dedupKey = `${sourceFile}->${edge.targetFile}`;
|
||||
if (seen.has(dedupKey)) continue;
|
||||
seen.add(dedupKey);
|
||||
|
||||
const sourceId = generateId('File', sourceFile);
|
||||
const targetId = generateId('File', edge.targetFile);
|
||||
graph.addRelationship({
|
||||
id: generateId('IMPORTS', dedupKey),
|
||||
sourceId,
|
||||
targetId,
|
||||
type: 'IMPORTS',
|
||||
confidence: 1.0,
|
||||
reason: 'python-scope: import',
|
||||
});
|
||||
emitted++;
|
||||
}
|
||||
}
|
||||
|
||||
return emitted;
|
||||
}
|
||||
|
||||
// `BindingRef` is intentionally exported back through the public surface
|
||||
// so callers extending the orchestrator's hook adapters don't have to
|
||||
// chase the import to `gitnexus-shared`. Pass-through only.
|
||||
export type { BindingRef };
|
||||
231
gitnexus/src/core/ingestion/resolve-references.ts
Normal file
231
gitnexus/src/core/ingestion/resolve-references.ts
Normal file
|
|
@ -0,0 +1,231 @@
|
|||
/**
|
||||
* `resolveReferenceSites` — drain `ReferenceSite[]` from a finalized
|
||||
* `ScopeResolutionIndexes` into a `ReferenceIndex` by routing each site
|
||||
* through the appropriate scope-aware `Registry.lookup` (RFC §3.2 Phase 4).
|
||||
*
|
||||
* This is the missing producer that `emit-references.ts` (#925) was
|
||||
* waiting on. The two together form the registry-primary resolution
|
||||
* pipeline:
|
||||
*
|
||||
* ScopeResolutionIndexes.referenceSites
|
||||
* │ resolveReferenceSites
|
||||
* ▼
|
||||
* ReferenceIndex
|
||||
* │ emitReferencesToGraph
|
||||
* ▼
|
||||
* graph: CALLS / ACCESSES / INHERITS / USES edges
|
||||
*
|
||||
* ## What this module does
|
||||
*
|
||||
* - For each `ReferenceSite`, picks the registry by `kind`:
|
||||
* · `call` / `inherits` → MethodRegistry / ClassRegistry (call-form aware)
|
||||
* · `read` / `write` → FieldRegistry (falls through to MethodRegistry for free names)
|
||||
* · `type-reference` → ClassRegistry
|
||||
* · `import-use` → all three (best-effort name-lookup)
|
||||
* - Calls `Registry.lookup` with the site's `inScope`, optional
|
||||
* explicit receiver, and arity.
|
||||
* - Takes the top-ranked `Resolution` (best by confidence + tie-break
|
||||
* cascade); folds it into a `Reference` record and bins by source scope.
|
||||
*
|
||||
* ## What this module does NOT do
|
||||
*
|
||||
* - No AST walks. The `ReferenceSite[]` is already extracted.
|
||||
* - No language switches. Per-language behavior flows through
|
||||
* `RegistryProviders.arityCompatibility` (see `RegistryContext`).
|
||||
* - No multi-candidate fan-out. We pick `[0]` per RFC §4.3 ("one-shot
|
||||
* answer"). The full ranked list is preserved in the per-site
|
||||
* resolution but not emitted as multiple edges; callers that want
|
||||
* branch-on-ambiguity behavior should consume the registries directly.
|
||||
*/
|
||||
|
||||
import {
|
||||
buildClassRegistry,
|
||||
buildFieldRegistry,
|
||||
buildMethodRegistry,
|
||||
CLASS_KINDS,
|
||||
FIELD_KINDS,
|
||||
METHOD_KINDS,
|
||||
type ClassRegistry,
|
||||
type FieldRegistry,
|
||||
type MethodRegistry,
|
||||
type Reference,
|
||||
type ReferenceIndex,
|
||||
type ReferenceSite,
|
||||
type RegistryContext,
|
||||
type RegistryProviders,
|
||||
type Resolution,
|
||||
type ScopeId,
|
||||
} from 'gitnexus-shared';
|
||||
import type { ScopeResolutionIndexes } from './model/scope-resolution-indexes.js';
|
||||
|
||||
// ─── Public API ─────────────────────────────────────────────────────────────
|
||||
|
||||
export interface ResolveReferencesInput {
|
||||
readonly scopes: ScopeResolutionIndexes;
|
||||
/** Provider hooks consumed by the registries (e.g. `arityCompatibility`). */
|
||||
readonly providers?: RegistryProviders;
|
||||
}
|
||||
|
||||
export interface ResolveStats {
|
||||
readonly sitesProcessed: number;
|
||||
readonly referencesEmitted: number;
|
||||
/** Sites where `Registry.lookup` returned no candidates. */
|
||||
readonly unresolved: number;
|
||||
}
|
||||
|
||||
export interface ResolveReferencesOutput {
|
||||
readonly referenceIndex: ReferenceIndex;
|
||||
readonly stats: ResolveStats;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve every `ReferenceSite` in `scopes.referenceSites` against the
|
||||
* matching registry and produce a `ReferenceIndex` keyed by source scope
|
||||
* + target def.
|
||||
*/
|
||||
export function resolveReferenceSites(input: ResolveReferencesInput): ResolveReferencesOutput {
|
||||
const { scopes } = input;
|
||||
const providers: RegistryProviders = input.providers ?? {};
|
||||
|
||||
const ctx: RegistryContext = {
|
||||
scopes: scopes.scopeTree,
|
||||
defs: scopes.defs,
|
||||
qualifiedNames: scopes.qualifiedNames,
|
||||
moduleScopes: scopes.moduleScopes,
|
||||
methodDispatch: scopes.methodDispatch,
|
||||
providers,
|
||||
};
|
||||
|
||||
const classRegistry = buildClassRegistry(ctx);
|
||||
const methodRegistry = buildMethodRegistry(ctx);
|
||||
const fieldRegistry = buildFieldRegistry(ctx);
|
||||
|
||||
// bySourceScope is the canonical index; byTargetDef is derived from it.
|
||||
const bySourceScope = new Map<ScopeId, Reference[]>();
|
||||
const byTargetDef = new Map<string, Reference[]>();
|
||||
|
||||
let sitesProcessed = 0;
|
||||
let referencesEmitted = 0;
|
||||
let unresolved = 0;
|
||||
|
||||
for (const site of scopes.referenceSites) {
|
||||
sitesProcessed++;
|
||||
|
||||
const resolutions = lookupForSite(site, classRegistry, methodRegistry, fieldRegistry);
|
||||
if (resolutions.length === 0) {
|
||||
unresolved++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const top = resolutions[0]!;
|
||||
const ref = buildReference(site, top);
|
||||
referencesEmitted++;
|
||||
|
||||
let bySource = bySourceScope.get(site.inScope);
|
||||
if (bySource === undefined) {
|
||||
bySource = [];
|
||||
bySourceScope.set(site.inScope, bySource);
|
||||
}
|
||||
bySource.push(ref);
|
||||
|
||||
let byTarget = byTargetDef.get(top.def.nodeId);
|
||||
if (byTarget === undefined) {
|
||||
byTarget = [];
|
||||
byTargetDef.set(top.def.nodeId, byTarget);
|
||||
}
|
||||
byTarget.push(ref);
|
||||
}
|
||||
|
||||
// Freeze inner arrays so consumers don't accidentally mutate.
|
||||
const frozenBySource = new Map<ScopeId, readonly Reference[]>();
|
||||
for (const [k, v] of bySourceScope) frozenBySource.set(k, Object.freeze([...v]));
|
||||
const frozenByTarget = new Map<string, readonly Reference[]>();
|
||||
for (const [k, v] of byTargetDef) frozenByTarget.set(k, Object.freeze([...v]));
|
||||
|
||||
return {
|
||||
referenceIndex: { bySourceScope: frozenBySource, byTargetDef: frozenByTarget },
|
||||
stats: { sitesProcessed, referencesEmitted, unresolved },
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Internal ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Pick the right registry for the site's `kind` and call `lookup`.
|
||||
*
|
||||
* The kind→registry mapping mirrors `mapKindToType` in `emit-references.ts`:
|
||||
*
|
||||
* | site.kind | primary registry | acceptedKinds source |
|
||||
* |------------------|-------------------|------------------------------|
|
||||
* | `call` | MethodRegistry | METHOD_KINDS (Method/Func/Ctor)
|
||||
* | `inherits` | ClassRegistry | CLASS_KINDS |
|
||||
* | `type-reference` | ClassRegistry | CLASS_KINDS |
|
||||
* | `read`/`write` | FieldRegistry | FIELD_KINDS |
|
||||
* | `import-use` | tiered fallback | METHOD ∪ CLASS ∪ FIELD |
|
||||
*
|
||||
* `import-use` doesn't have a single registry — the imported name might
|
||||
* be a class, a function, or a constant. Try each in priority order and
|
||||
* return the first non-empty result. Provenance still flows through the
|
||||
* scope's `bindings` (Step 1 lexical hit), so the lookup is correct
|
||||
* regardless of which registry surfaces the def.
|
||||
*/
|
||||
function lookupForSite(
|
||||
site: ReferenceSite,
|
||||
classRegistry: ClassRegistry,
|
||||
methodRegistry: MethodRegistry,
|
||||
fieldRegistry: FieldRegistry,
|
||||
): readonly Resolution[] {
|
||||
switch (site.kind) {
|
||||
case 'call': {
|
||||
const opts: Parameters<MethodRegistry['lookup']>[2] = {
|
||||
...(site.arity !== undefined ? { callsite: { arity: site.arity } } : {}),
|
||||
...(site.explicitReceiver !== undefined
|
||||
? { explicitReceiver: site.explicitReceiver }
|
||||
: {}),
|
||||
};
|
||||
return methodRegistry.lookup(site.name, site.inScope, opts);
|
||||
}
|
||||
case 'inherits':
|
||||
case 'type-reference': {
|
||||
return classRegistry.lookup(site.name, site.inScope);
|
||||
}
|
||||
case 'read':
|
||||
case 'write': {
|
||||
// Try field first; fall through to method then class so bare-name
|
||||
// reads of a function (e.g. `cb = save`) still resolve.
|
||||
const fieldHits = fieldRegistry.lookup(site.name, site.inScope);
|
||||
if (fieldHits.length > 0) return fieldHits;
|
||||
const methodHits = methodRegistry.lookup(site.name, site.inScope);
|
||||
if (methodHits.length > 0) return methodHits;
|
||||
return classRegistry.lookup(site.name, site.inScope);
|
||||
}
|
||||
case 'import-use': {
|
||||
// Try class, method, then field. The lexical-hit Step 1 in
|
||||
// `lookupCore` handles the actual binding lookup; the choice of
|
||||
// registry only narrows `acceptedKinds`.
|
||||
const classHits = classRegistry.lookup(site.name, site.inScope);
|
||||
if (classHits.length > 0) return classHits;
|
||||
const methodHits = methodRegistry.lookup(site.name, site.inScope);
|
||||
if (methodHits.length > 0) return methodHits;
|
||||
return fieldRegistry.lookup(site.name, site.inScope);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Compose a `Reference` record from a site + its top resolution. */
|
||||
function buildReference(site: ReferenceSite, top: Resolution): Reference {
|
||||
return {
|
||||
fromScope: site.inScope,
|
||||
toDef: top.def.nodeId,
|
||||
atRange: site.atRange,
|
||||
kind: site.kind,
|
||||
confidence: top.confidence,
|
||||
evidence: top.evidence,
|
||||
};
|
||||
}
|
||||
|
||||
// Re-export the kind sets so consumers don't have to import them
|
||||
// separately when constructing custom resolution flows. The mappings
|
||||
// stay in `gitnexus-shared` (single source of truth); this is a
|
||||
// convenience pass-through only.
|
||||
export { CLASS_KINDS, METHOD_KINDS, FIELD_KINDS };
|
||||
Loading…
Add table
Reference in a new issue