mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-01 02:01:24 +00:00
Merge branch 'main' into fix/fts-non-fatal-in-analyze
This commit is contained in:
commit
62a5774909
9 changed files with 336 additions and 10 deletions
|
|
@ -34,6 +34,7 @@ import {
|
|||
mroPhase,
|
||||
communitiesPhase,
|
||||
processesPhase,
|
||||
type ScopeResolutionOutput,
|
||||
type PipelinePhase,
|
||||
type CommunitiesOutput,
|
||||
type ProcessesOutput,
|
||||
|
|
@ -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;
|
||||
|
|
@ -208,6 +213,7 @@ export const runPipelineFromRepo = async (
|
|||
totalFileCount: totalFiles,
|
||||
communityResult,
|
||||
processResult,
|
||||
resolutionOutcomes,
|
||||
usedWorkerPool,
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
@ -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,
|
||||
|
|
@ -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];
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
@ -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 /
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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) {
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ import { readFileContents } from '../../filesystem-walker.js';
|
|||
import { runScopeResolution } from './run.js';
|
||||
import { SCOPE_RESOLVERS } from './registry.js';
|
||||
import { isDev, isSemanticModelValidatorEnabled } from '../../utils/env.js';
|
||||
import type { ResolutionOutcome } from '../resolution-outcome.js';
|
||||
|
||||
import { logger } from '../../../logger.js';
|
||||
export interface ScopeResolutionOutput {
|
||||
|
|
@ -48,6 +49,8 @@ export interface ScopeResolutionOutput {
|
|||
readonly importsEmitted: number;
|
||||
/** Reference (CALLS / ACCESSES / INHERITS / USES) edges emitted. */
|
||||
readonly referenceEdgesEmitted: number;
|
||||
/** Additive stream of resolver diagnostics; does not affect graph edges. */
|
||||
readonly resolutionOutcomes: readonly ResolutionOutcome[];
|
||||
/** Per-language breakdown for telemetry / shadow-parity. */
|
||||
readonly perLanguage: ReadonlyMap<
|
||||
SupportedLanguages,
|
||||
|
|
@ -64,6 +67,7 @@ const NOOP_OUTPUT: ScopeResolutionOutput = Object.freeze({
|
|||
filesProcessed: 0,
|
||||
importsEmitted: 0,
|
||||
referenceEdgesEmitted: 0,
|
||||
resolutionOutcomes: [],
|
||||
perLanguage: new Map(),
|
||||
});
|
||||
|
||||
|
|
@ -116,6 +120,7 @@ export const scopeResolutionPhase: PipelinePhase<ScopeResolutionOutput> = {
|
|||
let totalImports = 0;
|
||||
let totalRefs = 0;
|
||||
let anyRan = false;
|
||||
const resolutionOutcomes: ResolutionOutcome[] = [];
|
||||
const perLanguage = new Map<
|
||||
SupportedLanguages,
|
||||
{
|
||||
|
|
@ -156,6 +161,9 @@ export const scopeResolutionPhase: PipelinePhase<ScopeResolutionOutput> = {
|
|||
treeCache: scopeTreeCache,
|
||||
resolutionConfig,
|
||||
preExtractedParsedFiles: preExtractedByPath,
|
||||
recordResolutionOutcome: (outcome) => {
|
||||
resolutionOutcomes.push(outcome);
|
||||
},
|
||||
onWarn: (msg) => {
|
||||
if (isSemanticModelValidatorEnabled()) {
|
||||
logger.warn(`[scope-resolution:${lang}] ${msg}`);
|
||||
|
|
@ -197,6 +205,7 @@ export const scopeResolutionPhase: PipelinePhase<ScopeResolutionOutput> = {
|
|||
filesProcessed: totalFiles,
|
||||
importsEmitted: totalImports,
|
||||
referenceEdgesEmitted: totalRefs,
|
||||
resolutionOutcomes,
|
||||
perLanguage,
|
||||
};
|
||||
},
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ import { emitImportEdges } from '../graph-bridge/imports-to-edges.js';
|
|||
import type { ScopeResolver } from '../contract/scope-resolver.js';
|
||||
import { findClassBindingInScope, findEnclosingClassDef } from '../scope/walkers.js';
|
||||
import { buildWorkspaceResolutionIndex } from '../workspace-index.js';
|
||||
import type { ResolutionOutcome, ResolutionOutcomeRecorder } from '../resolution-outcome.js';
|
||||
|
||||
import { logger } from '../../../logger.js';
|
||||
|
||||
|
|
@ -161,6 +162,11 @@ interface RunScopeResolutionInput {
|
|||
* Cache miss is safe — falls back to fresh extract.
|
||||
*/
|
||||
readonly preExtractedParsedFiles?: ReadonlyMap<string, ParsedFile>;
|
||||
/**
|
||||
* Optional additive diagnostics sink. Resolver passes call this when they
|
||||
* intentionally suppress an edge; the graph remains unchanged.
|
||||
*/
|
||||
readonly recordResolutionOutcome?: ResolutionOutcomeRecorder;
|
||||
}
|
||||
|
||||
interface RunScopeResolutionStats {
|
||||
|
|
@ -170,6 +176,7 @@ interface RunScopeResolutionStats {
|
|||
readonly resolve: ResolveStats;
|
||||
readonly referenceEdgesEmitted: number;
|
||||
readonly referenceSkipped: number;
|
||||
readonly resolutionOutcomes: readonly ResolutionOutcome[];
|
||||
}
|
||||
|
||||
export function runScopeResolution(
|
||||
|
|
@ -178,6 +185,11 @@ export function runScopeResolution(
|
|||
): RunScopeResolutionStats {
|
||||
const { graph, files } = input;
|
||||
const onWarn = input.onWarn ?? (() => {});
|
||||
const resolutionOutcomes: ResolutionOutcome[] = [];
|
||||
const recordResolutionOutcome: ResolutionOutcomeRecorder = (outcome) => {
|
||||
resolutionOutcomes.push(outcome);
|
||||
input.recordResolutionOutcome?.(outcome);
|
||||
};
|
||||
const PROF = process.env.PROF_SCOPE_RESOLUTION === '1';
|
||||
const tStart = PROF ? process.hrtime.bigint() : 0n;
|
||||
let fileContents: Map<string, string> | undefined;
|
||||
|
|
@ -248,6 +260,7 @@ export function runScopeResolution(
|
|||
resolve: { sitesProcessed: 0, referencesEmitted: 0, unresolved: 0 },
|
||||
referenceEdgesEmitted: 0,
|
||||
referenceSkipped: 0,
|
||||
resolutionOutcomes,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -359,6 +372,9 @@ export function runScopeResolution(
|
|||
provider,
|
||||
workspaceIndex,
|
||||
readonlyModel,
|
||||
{
|
||||
recordResolutionOutcome,
|
||||
},
|
||||
);
|
||||
const unresolvedReceiverExtras =
|
||||
provider.emitUnresolvedReceiverEdges !== undefined
|
||||
|
|
@ -387,6 +403,7 @@ export function runScopeResolution(
|
|||
resolveAdlCandidates: provider.resolveAdlCandidates,
|
||||
conversionRankFn: provider.conversionRankFn,
|
||||
constraintCompatibility: provider.constraintCompatibility,
|
||||
recordResolutionOutcome,
|
||||
},
|
||||
);
|
||||
const { emitted, skipped } = emitReferencesViaLookup(
|
||||
|
|
@ -424,5 +441,6 @@ export function runScopeResolution(
|
|||
resolve: resolveStats,
|
||||
referenceEdgesEmitted: emitted + receiverExtras + unresolvedReceiverExtras + freeCallExtras,
|
||||
referenceSkipped: skipped,
|
||||
resolutionOutcomes,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,34 @@
|
|||
import type { Range } from 'gitnexus-shared';
|
||||
|
||||
export type ResolutionSuppressionReason =
|
||||
| 'adl-ordinary-lookup-blocked'
|
||||
| 'conversion-rank-tied'
|
||||
| 'inline-ns-ambiguous'
|
||||
| 'overload-ambiguous'
|
||||
| 'overload-ambiguous-normalization';
|
||||
|
||||
export type ResolutionOutcome =
|
||||
| {
|
||||
readonly kind: 'resolved';
|
||||
readonly targetId: string;
|
||||
readonly phase: string;
|
||||
readonly filePath: string;
|
||||
readonly name: string;
|
||||
readonly range: Range;
|
||||
}
|
||||
| {
|
||||
readonly kind: 'suppressed';
|
||||
readonly reason: ResolutionSuppressionReason;
|
||||
/**
|
||||
* Scope-resolution definition IDs considered by the suppression decision.
|
||||
* For `inline-ns-ambiguous` this is currently empty because the
|
||||
* qualified namespace resolver returns only an `ambiguous` sentinel.
|
||||
*/
|
||||
readonly candidateIds: readonly string[];
|
||||
readonly phase: string;
|
||||
readonly filePath: string;
|
||||
readonly name: string;
|
||||
readonly range: Range;
|
||||
};
|
||||
|
||||
export type ResolutionOutcomeRecorder = (outcome: ResolutionOutcome) => void;
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import type { KnowledgeGraph } from '../core/graph/types.js';
|
||||
import { CommunityDetectionResult } from '../core/ingestion/community-processor.js';
|
||||
import { ProcessDetectionResult } from '../core/ingestion/process-processor.js';
|
||||
import type { ResolutionOutcome } from '../core/ingestion/scope-resolution/resolution-outcome.js';
|
||||
|
||||
// CLI-specific: in-memory result with graph + detection results
|
||||
export interface PipelineResult {
|
||||
|
|
@ -11,6 +12,12 @@ export interface PipelineResult {
|
|||
totalFileCount: number;
|
||||
communityResult?: CommunityDetectionResult;
|
||||
processResult?: ProcessDetectionResult;
|
||||
/**
|
||||
* Additive diagnostics for registry-primary resolution decisions that
|
||||
* deliberately suppress edge emission. Empty means no diagnostic was
|
||||
* produced; graph edge semantics are unchanged.
|
||||
*/
|
||||
resolutionOutcomes: readonly ResolutionOutcome[];
|
||||
/**
|
||||
* True if the parse phase spawned a worker pool for this run. False means
|
||||
* the sequential fallback handled every chunk. Primarily a test affordance
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
getRelationships,
|
||||
getNodesByLabel,
|
||||
getNodesByLabelFull,
|
||||
getResolutionOutcomes,
|
||||
edgeSet,
|
||||
runPipelineFromRepo,
|
||||
createResolverParityIt,
|
||||
|
|
@ -1819,6 +1820,21 @@ describe('C++ ambiguous integer-width overloads', () => {
|
|||
// GitNexus does not have. The resolver must suppress entirely.
|
||||
expect(processCalls.length).toBe(0);
|
||||
});
|
||||
|
||||
it('records a structured suppression reason for normalization ambiguity', () => {
|
||||
const outcomes = getResolutionOutcomes(result).filter(
|
||||
(o) =>
|
||||
o.kind === 'suppressed' &&
|
||||
o.name === 'process' &&
|
||||
o.phase === 'receiver-bound-calls' &&
|
||||
o.filePath.endsWith('caller.cpp') &&
|
||||
o.reason === 'overload-ambiguous-normalization',
|
||||
);
|
||||
|
||||
expect(outcomes.length).toBeGreaterThan(0);
|
||||
expect(outcomes[0]?.candidateIds.length).toBe(2);
|
||||
expect(outcomes[0]?.range.startLine).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -1893,6 +1909,20 @@ describe('C++ overload resolution — conversion-rank disambiguation (#1578)', (
|
|||
// Contract: zero edges for ALL h() call sites combined (dedup).
|
||||
expect(hCalls.length).toBe(0);
|
||||
});
|
||||
|
||||
it('records a structured suppression reason for conversion-rank ties', () => {
|
||||
const outcomes = getResolutionOutcomes(result).filter(
|
||||
(o) =>
|
||||
o.kind === 'suppressed' &&
|
||||
o.name === 'h' &&
|
||||
o.phase === 'free-call-fallback' &&
|
||||
o.reason === 'conversion-rank-tied',
|
||||
);
|
||||
|
||||
expect(outcomes.length).toBeGreaterThan(0);
|
||||
expect(outcomes[0]?.candidateIds.length).toBe(2);
|
||||
expect(outcomes[0]?.range.startLine).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
// C++ overload resolution: pointer/nullptr/ellipsis conversion ranks (#1637)
|
||||
|
|
@ -2722,6 +2752,20 @@ describe('C++ ADL — non-function ordinary lookup suppresses ADL', () => {
|
|||
// `e` is audit::Event, audit::record should NOT be discovered.
|
||||
expect(recordCalls.length).toBe(0);
|
||||
});
|
||||
|
||||
it('records a structured suppression reason for ADL blocker lookup', () => {
|
||||
const outcomes = getResolutionOutcomes(result).filter(
|
||||
(o) =>
|
||||
o.kind === 'suppressed' &&
|
||||
o.name === 'record' &&
|
||||
o.phase === 'free-call-fallback' &&
|
||||
o.reason === 'adl-ordinary-lookup-blocked',
|
||||
);
|
||||
|
||||
expect(outcomes.length).toBeGreaterThan(0);
|
||||
expect(outcomes[0]?.candidateIds.length).toBe(0);
|
||||
expect(outcomes[0]?.range.startLine).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('C++ ADL — inner callable + outer non-callable: ADL not suppressed', () => {
|
||||
|
|
@ -2984,6 +3028,20 @@ describe('C++ inline namespace — ambiguous same-name across inline children (#
|
|||
// the same name. The resolver must suppress rather than pick arbitrarily.
|
||||
expect(fooCalls.length).toBe(0);
|
||||
});
|
||||
|
||||
it('records a structured suppression reason for inline namespace ambiguity', () => {
|
||||
const outcomes = getResolutionOutcomes(result).filter(
|
||||
(o) =>
|
||||
o.kind === 'suppressed' &&
|
||||
o.name === 'foo' &&
|
||||
o.phase === 'receiver-bound-calls' &&
|
||||
o.reason === 'inline-ns-ambiguous',
|
||||
);
|
||||
|
||||
expect(outcomes.length).toBeGreaterThan(0);
|
||||
expect(outcomes[0]?.candidateIds.length).toBe(0);
|
||||
expect(outcomes[0]?.range.startLine).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('C++ inline namespace — ambiguous distinct signatures (conservative suppress)', () => {
|
||||
|
|
|
|||
|
|
@ -218,6 +218,7 @@ const LEGACY_RESOLVER_PARITY_EXPECTED_FAILURES: Readonly<Record<string, Readonly
|
|||
// resolver-only correctness win (PR #1520 review follow-up plan U2 /
|
||||
// Claude review Finding 5); backporting to legacy is out of scope.
|
||||
'emits zero CALLS edges when process(int)/process(long) collide after normalization',
|
||||
'records a structured suppression reason for normalization ambiguity',
|
||||
// The legacy DAG path resolves `using namespace a; using namespace b; foo()`
|
||||
// by walking the workspace registry by simple name and binding to
|
||||
// the first match — same shape as the integer-width collision, just
|
||||
|
|
@ -299,6 +300,7 @@ const LEGACY_RESOLVER_PARITY_EXPECTED_FAILURES: Readonly<Record<string, Readonly
|
|||
// Multi-arg incomparable overloads: pairwise dominance check finds
|
||||
// neither h(int,int) nor h(double,double) dominates. Scope-resolver-only.
|
||||
'h(42, 2.5) emits zero CALLS edges — incomparable multi-arg overloads, ambiguous',
|
||||
'records a structured suppression reason for conversion-rank ties',
|
||||
// Pointer/nullptr/ellipsis conversion ranks (#1637) need C++ type-class
|
||||
// sidecars plus conversion-rank scoring. The legacy DAG has neither.
|
||||
'f(nullptr) and f(p) resolve to f(int*) while f(42) resolves to f(bool)',
|
||||
|
|
@ -328,6 +330,7 @@ const LEGACY_RESOLVER_PARITY_EXPECTED_FAILURES: Readonly<Record<string, Readonly
|
|||
// 'ambiguous' and suppresses edge emission. Scope-resolver-only
|
||||
// correctness win (#1564); backporting to legacy is out of scope.
|
||||
'outer::foo() emits zero CALLS edges when v1 and v2 both declare foo',
|
||||
'records a structured suppression reason for inline namespace ambiguity',
|
||||
// Distinct-signature inline-namespace ambiguity: `foo(int)` in v1 and
|
||||
// `foo(double)` in v2. The scope-resolver conservatively suppresses
|
||||
// because `resolveQualifiedReceiverMember` lacks call-site argument
|
||||
|
|
@ -348,6 +351,8 @@ const LEGACY_RESOLVER_PARITY_EXPECTED_FAILURES: Readonly<Record<string, Readonly
|
|||
// scope-resolver-only. The legacy DAG has no scope-aware ADL blocker
|
||||
// detection; it falls back to `pickUniqueGlobalCallable`. Scope-
|
||||
// resolver-only correctness wins; backporting is out of scope.
|
||||
'record(e) emits zero CALLS when a variable named record exists in scope',
|
||||
'records a structured suppression reason for ADL blocker lookup',
|
||||
'swap(a,b) resolves to data::swap when inner scope has callable swap and outer has variable',
|
||||
'record(e) emits zero CALLS when a block-scope function declaration exists',
|
||||
]),
|
||||
|
|
@ -429,6 +434,10 @@ export function getRelationships(result: PipelineResult, type: string): RelEdge[
|
|||
return edges;
|
||||
}
|
||||
|
||||
export function getResolutionOutcomes(result: PipelineResult) {
|
||||
return result.resolutionOutcomes ?? [];
|
||||
}
|
||||
|
||||
export function getNodesByLabel(result: PipelineResult, label: string): string[] {
|
||||
const names: string[] = [];
|
||||
result.graph.forEachNode((n) => {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue