Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions gitnexus/src/core/ingestion/pipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import {
mroPhase,
communitiesPhase,
processesPhase,
type ScopeResolutionOutput,
type PipelinePhase,
type CommunitiesOutput,
type ProcessesOutput,
Expand Down Expand Up @@ -182,6 +183,10 @@ export const runPipelineFromRepo = async (

let communityResult: CommunitiesOutput['communityResult'] | undefined;
let processResult: ProcessesOutput['processResult'] | undefined;
const resolutionOutcomes = getPhaseOutput<ScopeResolutionOutput>(
results,
'scopeResolution',
).resolutionOutcomes;

if (!options?.skipGraphPhases) {
communityResult = getPhaseOutput<CommunitiesOutput>(results, 'communities').communityResult;
Expand All @@ -208,6 +213,7 @@ export const runPipelineFromRepo = async (
totalFileCount: totalFiles,
communityResult,
processResult,
resolutionOutcomes,
usedWorkerPool,
};
};
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ import type { SemanticModel } from '../../model/semantic-model.js';
import type { WorkspaceResolutionIndex } from '../workspace-index.js';
import type { GraphNodeLookup } from '../graph-bridge/node-lookup.js';
import type { ScopeResolver } from '../contract/scope-resolver.js';
import type {
ResolutionOutcomeRecorder,
ResolutionSuppressionReason,
} from '../resolution-outcome.js';
import { resolveCallerGraphId, resolveDefGraphId } from '../graph-bridge/ids.js';
import {
findAllCallableBindingsInScope,
Expand Down Expand Up @@ -79,6 +83,7 @@ export function emitFreeCallFallback(
* fail at the call site. Three-valued; `'unknown'` keeps the
* candidate (monotonicity). */
readonly constraintCompatibility?: ScopeResolver['constraintCompatibility'];
readonly recordResolutionOutcome?: ResolutionOutcomeRecorder;
} = {},
): number {
let emitted = 0;
Expand Down Expand Up @@ -152,9 +157,18 @@ export function emitFreeCallFallback(
// Cross-file candidates are shadowing; keep first-match.
const sameFile = narrowed.every((d) => d.filePath === narrowed[0]!.filePath);
if (sameFile) {
handledSites.add(
`${parsed.filePath}:${site.atRange.startLine}:${site.atRange.startCol}`,
);
recordSuppressedOutcome(options.recordResolutionOutcome, {
phase: 'free-call-fallback',
filePath: parsed.filePath,
name: site.name,
range: site.atRange,
reason: suppressionReasonForOverload(narrowed, site.arity, {
conversionRankFn: options.conversionRankFn,
argumentTypes: site.argumentTypes,
}),
candidates: narrowed,
});
handledSites.add(siteKey(parsed.filePath, site));
continue;
}
}
Expand Down Expand Up @@ -186,7 +200,19 @@ export function emitFreeCallFallback(
parsedFiles,
);

const siteKey = `${parsed.filePath}:${site.atRange.startLine}:${site.atRange.startCol}`;
const key = siteKey(parsed.filePath, site);
if (adlSuppressed && ordinary.length === 0) {
recordSuppressedOutcome(options.recordResolutionOutcome, {
phase: 'free-call-fallback',
filePath: parsed.filePath,
name: site.name,
range: site.atRange,
reason: 'adl-ordinary-lookup-blocked',
candidates: ordinary,
});
handledSites.add(key);
continue;
}
if (adl === undefined || adl.length === 0) {
// No ADL contribution. Default behavior: `ordinary[0]` —
// scope-chain walk preserves local-shadows-import precedence.
Expand All @@ -210,7 +236,7 @@ export function emitFreeCallFallback(
if (narrowed.length === 1) {
fnDef = narrowed[0];
} else if (narrowed.length === 0) {
handledSites.add(siteKey);
handledSites.add(key);
continue;
} else {
// >1 survivors: same-file → suppress (true overloads,
Expand All @@ -219,7 +245,18 @@ export function emitFreeCallFallback(
// first-match (shadowing semantics).
const sameFile = narrowed.every((d) => d.filePath === narrowed[0]!.filePath);
if (sameFile) {
handledSites.add(siteKey);
recordSuppressedOutcome(options.recordResolutionOutcome, {
phase: 'free-call-fallback',
filePath: parsed.filePath,
name: site.name,
range: site.atRange,
reason: suppressionReasonForOverload(narrowed, site.arity, {
conversionRankFn: options.conversionRankFn,
argumentTypes: site.argumentTypes,
}),
candidates: narrowed,
});
handledSites.add(key);
continue;
}
fnDef = ordinary[0];
Expand All @@ -246,16 +283,27 @@ export function emitFreeCallFallback(
if (narrowed.length === 1) {
fnDef = narrowed[0];
} else if (narrowed.length === 0) {
handledSites.add(siteKey);
handledSites.add(key);
continue;
} else if (narrowed.length > 1) {
recordSuppressedOutcome(options.recordResolutionOutcome, {
phase: 'free-call-fallback',
filePath: parsed.filePath,
name: site.name,
range: site.atRange,
reason: suppressionReasonForOverload(narrowed, site.arity, {
conversionRankFn: options.conversionRankFn,
argumentTypes: site.argumentTypes,
}),
candidates: narrowed,
});
if (isOverloadAmbiguousAfterNormalization(narrowed, site.arity)) {
handledSites.add(siteKey);
handledSites.add(key);
continue;
}
// Multiple survivors remain after conversion-rank scoring;
// suppress instead of picking arbitrarily.
handledSites.add(siteKey);
handledSites.add(key);
continue;
}
}
Expand Down Expand Up @@ -295,7 +343,7 @@ export function emitFreeCallFallback(
// 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}`);
handledSites.add(siteKey(parsed.filePath, site));
const relId = `rel:CALLS:${callerGraphId}->${tgtGraphId}`;
if (seen.has(relId)) continue;
seen.add(relId);
Expand All @@ -315,6 +363,61 @@ export function emitFreeCallFallback(
return emitted;
}

function siteKey(
filePath: string,
site: { readonly atRange: { readonly startLine: number; readonly startCol: number } },
): string {
return `${filePath}:${site.atRange.startLine}:${site.atRange.startCol}`;
}

function suppressionReasonForOverload(
candidates: readonly SymbolDefinition[],
arity: number | undefined,
ctx: {
readonly conversionRankFn?: ConversionRankFn;
readonly argumentTypes?: readonly string[];
},
): ResolutionSuppressionReason {
if (isOverloadAmbiguousAfterNormalization(candidates, arity)) {
return 'overload-ambiguous-normalization';
}
if (
ctx.conversionRankFn !== undefined &&
ctx.argumentTypes !== undefined &&
ctx.argumentTypes.length > 0
) {
return 'conversion-rank-tied';
}
return 'overload-ambiguous';
}

function recordSuppressedOutcome(
record: ResolutionOutcomeRecorder | undefined,
input: {
readonly phase: string;
readonly filePath: string;
readonly name: string;
readonly range: {
readonly startLine: number;
readonly startCol: number;
readonly endLine: number;
readonly endCol: number;
};
readonly reason: ResolutionSuppressionReason;
readonly candidates: readonly SymbolDefinition[];
},
): void {
record?.({
kind: 'suppressed',
phase: input.phase,
filePath: input.filePath,
name: input.name,
range: input.range,
reason: input.reason,
candidateIds: input.candidates.map((d) => d.nodeId),
});
}

/**
* Build a `simpleName -> callable defs` index from `scopes.defs` once per
* pass. Mirrors the filter the old per-site scan applied: Function /
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,10 @@ import {
extractTemplateArguments,
stripTemplateArguments,
} from '../../utils/template-arguments.js';
import type {
ResolutionOutcomeRecorder,
ResolutionSuppressionReason,
} from '../resolution-outcome.js';

/** Subset of `ScopeResolver` consumed by this pass. Accepting the
* subset rather than the full provider keeps tests and partial
Expand Down Expand Up @@ -140,6 +144,9 @@ export function emitReceiverBoundCalls(
provider: ReceiverBoundProviderSubset,
index: WorkspaceResolutionIndex,
model: SemanticModel,
options: {
readonly recordResolutionOutcome?: ResolutionOutcomeRecorder;
} = {},
): number {
let emitted = 0;
// Per-pass dedup so the multiple cases don't double-emit if two of
Expand Down Expand Up @@ -498,6 +505,15 @@ export function emitReceiverBoundCalls(
if (memberDef === 'ambiguous') {
// Same-name ambiguity across inline-namespace children (#1564):
// suppress edge emission, mark site handled.
options.recordResolutionOutcome?.({
kind: 'suppressed',
phase: 'receiver-bound-calls',
filePath: parsed.filePath,
name: site.name,
range: site.atRange,
reason: 'inline-ns-ambiguous',
candidateIds: [],
});
handledSites.add(siteKey);
continue;
}
Expand Down Expand Up @@ -702,6 +718,7 @@ export function emitReceiverBoundCalls(
const chain = [ownerDef.nodeId, ...scopes.methodDispatch.mroFor(ownerDef.nodeId)];
let memberDef: SymbolDefinition | undefined;
let ambiguous = false;
let ambiguousOwnerId: string | undefined;
// Track whether the chain walk filtered out any static-only
// candidates. When it did and the chain ended with no
// legitimate instance member, we mark the site as handled so
Expand Down Expand Up @@ -733,6 +750,7 @@ export function emitReceiverBoundCalls(
const picked = pickFirstNonStaticOnly(ownerId, memberName, site, model, provider);
if (picked === OVERLOAD_AMBIGUOUS) {
ambiguous = true;
ambiguousOwnerId = ownerId;
break;
}
if (picked === STATIC_ONLY_FILTERED) {
Expand All @@ -754,6 +772,15 @@ export function emitReceiverBoundCalls(
// Suppress and mark handled so `emitReferencesViaLookup`
// doesn't re-emit the pre-resolved reference. See
// OVERLOAD_AMBIGUOUS docstring for the upstream cause.
recordReceiverOverloadSuppression(
options.recordResolutionOutcome,
parsed.filePath,
site,
ambiguousOwnerId ?? ownerDef.nodeId,
memberName,
model,
provider,
);
handledSites.add(siteKey);
continue;
}
Expand Down Expand Up @@ -830,6 +857,15 @@ export function emitReceiverBoundCalls(
resolveDefGraphId(valueDef.filePath, valueDef, nodeLookup) ?? valueDef.nodeId;
const picked = pickOverload(ownerGraphId, memberName, site, model, provider);
if (picked === OVERLOAD_AMBIGUOUS) {
recordReceiverOverloadSuppression(
options.recordResolutionOutcome,
parsed.filePath,
site,
ownerGraphId,
memberName,
model,
provider,
);
handledSites.add(siteKey);
continue;
}
Expand Down Expand Up @@ -1017,3 +1053,49 @@ function pickFirstNonStaticOnly(
if (candidates.length > 1) return OVERLOAD_AMBIGUOUS;
return candidates[0] ?? overloads[0];
}

function recordReceiverOverloadSuppression(
record: ResolutionOutcomeRecorder | undefined,
filePath: string,
site: ParsedFile['referenceSites'][number],
ownerId: string,
memberName: string,
model: SemanticModel,
provider: ReceiverBoundProviderSubset,
): void {
if (record === undefined) return;
const overloads = model.methods.lookupAllByOwner(ownerId, memberName);
const candidates = narrowOverloadCandidates(overloads, site.arity, site.argumentTypes, {
argumentTypeClasses: site.argumentTypeClasses,
conversionRankFn: provider.conversionRankFn,
constraintCompatibility: provider.constraintCompatibility,
});
const reason: ResolutionSuppressionReason = isOverloadAmbiguousAfterNormalization(
candidates,
site.arity,
)
? 'overload-ambiguous-normalization'
: hasConversionRankingSignal(site, provider)
? 'conversion-rank-tied'
: 'overload-ambiguous';
record({
kind: 'suppressed',
phase: 'receiver-bound-calls',
filePath,
name: site.name,
range: site.atRange,
reason,
candidateIds: candidates.map((d) => d.nodeId),
});
}

function hasConversionRankingSignal(
site: ParsedFile['referenceSites'][number],
provider: ReceiverBoundProviderSubset,
): boolean {
return (
provider.conversionRankFn !== undefined &&
site.argumentTypes !== undefined &&
site.argumentTypes.length > 0
);
}
Loading
Loading