mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-09 03:17:54 +00:00
fix(go): resolve out-of-repo package qualifiers, and stop reporting an undecided interface check as a decided negative (#2873) (#2921)
This commit is contained in:
parent
414c1a5693
commit
135bcae03d
28 changed files with 1520 additions and 198 deletions
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"_comment": "Per-language baselines for bench/scope-capture/measure.mjs --check. fingerprint = order-independent sha256 over the lang-resolution/<lang>-* fixture corpus + a 20-entity synthetic source (correctness gate; re-baseline intentionally on a legitimate capture change). scaling_budget = max allowed (t800/t250)/(800/250); ~1.0 is linear, ~3.2 is quadratic. The synthetic source is now HERITAGE-BEARING for every language (each Entity extends/implements/embeds/uses-trait/conforms-to a shared base) so the #1951 @reference.inherits synth is gated at scale, not just the base capture loop. All languages thread the tree-sitter captured node instead of re-deriving it with findNodeAtRange(tree.rootNode,...) per match, so all are linear (go #1915, python #1918, ruby/php/rust/csharp #1951, java #1956).",
|
||||
"go": {
|
||||
"fingerprint": "e386598526e502d131e52a17d219635b3a4196d94f1ebdd25922a2582c985d18",
|
||||
"fingerprint": "9c554a9d698a2b79fb419852daadca87b8aae88180cceabf9c8d82f3e3300f2e",
|
||||
"scaling_budget": 1.5,
|
||||
"_rebaselined_callable_flow_2522_review": "PR #2522 review hardening: callable operands retain expression/qualified identity and formals retain signature metadata. Prior 3d4e32e7490c830516126e28931827949baa3594cb521f7a3d8dcfed95b6018a -> 57b3c55135af8d2af33b9a7c4bf89796a7bee5b5822b402a2dea91af7232cf4a; scaling 1.058 < 1.5.",
|
||||
"_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: provider-owned callable assignment/copy/formal/argument/invoke facts with invocation/constructor-result suppression. Prior 09ecd94911b830f52fa8807560abcbd79f163d02a2072870c1a59297e9a326e1 -> 3d4e32e7490c830516126e28931827949baa3594cb521f7a3d8dcfed95b6018a; scaling 1.039 < 1.5.",
|
||||
|
|
@ -13,7 +13,8 @@
|
|||
"_rebaselined_2766_phantom_callee_read_site": "#2766: Go's `@reference.read` pattern matches EVERY selector_expression, so a member call `h.dep.Work()` minted THREE sites — the call, the genuine `h.dep` field read, and a PHANTOM read on the callee `h.dep.Work`. The phantom resolved through findOwnedMember (which prefers methods over fields) and emitted an ACCESSES edge to the METHOD duplicating the CALLS edge at the same position; visible today on any receiver the text cascade can type (`RunFromValueReceiver -> DoWork`). The emitter now drops a read match whose selector is in FUNCTION position. FEWER capture matches for Go, no other language affected — go was the only fingerprint of 15 that moved. A method VALUE (`f := h.dep.Work`) is not in function position and is untouched. Prior c9c908f441e3be12fad2448120ed3ea35dc235a12b3f63b0ec532ffdae11d9e9 -> 7bb524a32a2eed57a15b454e3a33480e92a496c683e6856ef02179693c0e02e3.",
|
||||
"_rebaselined_2766_callee_position_marker": "#2766 review fix: a call's callee selector is no longer DROPPED at capture. An earlier commit on this branch dropped it outright, which also deleted the genuine field read on a func-typed struct field (`h.dep.Work()` where `Work func() error`) - callback/hook/mock structs lost their only ACCESSES evidence. The match is now emitted carrying `@reference.callee-position`, and the phantom is suppressed at EMIT by the resolved target's kind instead. Go only: the other 14 languages' fingerprints are byte-identical, which is the check that this is not a cross-language capture change. Prior 7bb524a32a2eed57a15b454e3a33480e92a496c683e6856ef02179693c0e02e3 -> e47302079e17a5e73711bbed5416557b49327cb67e4932008700ec6b8fb468b3; scaling 1.001 < 1.5; fixtures 102 (unchanged), capture_groups_fp 2103.",
|
||||
"_rebaselined_2813_interface_field_dispatch_fixture": "#2813: added test/fixtures/lang-resolution/go-interface-field-dispatch/ (8 Go files) as the committed regression fixture for calls through an interface-typed struct field. Go fixture_count 102 -> 110. FIXTURE-CORPUS GROWTH, NOT A CAPTURE CHANGE: the accompanying fixes are a detection-time method-set change (interface-impls.ts) and a resolution-time fan-out in the shared receiver pass, neither of which emits captures; go/query.ts and go/captures.ts are untouched. Go was the ONLY language whose fingerprint drifted, and every other language matched its baseline on the same run - the same check used for the #2766 fixture growth above. Prior e47302079e17a5e73711bbed5416557b49327cb67e4932008700ec6b8fb468b3 -> cffee41cadbf350855d99bd5aee7c015b1e8b31d1c343d02f113540abe86c765; scaling 1.074 < 1.5, capture_groups_fp 2303.",
|
||||
"_rebaselined_2837": "#2837: Go struct/interface captures re-anchored from the type_declaration onto the type_spec (@scope.class/@declaration.struct/@declaration.interface in languages/go/query.ts, @definition.struct/@definition.interface in GO_QUERIES). A grouped `type (...)` block used to yield ONE scope and ONE node for every type in it, so each type after the first lost its field typeBindings and every field-receiver call in the file emitted nothing. Capture COUNT is unchanged; only ranges moved, plus the new go-grouped-type-decl fixture. Prior c27fb803598581fa4eb7ddf5ef6f8369b9e3a150082d11362e7aa3ec8faaa832 -> e386598526e502d131e52a17d219635b3a4196d94f1ebdd25922a2582c985d18; scaling 1.054 < 1.5."
|
||||
"_rebaselined_2837": "#2837: Go struct/interface captures re-anchored from the type_declaration onto the type_spec (@scope.class/@declaration.struct/@declaration.interface in languages/go/query.ts, @definition.struct/@definition.interface in GO_QUERIES). A grouped `type (...)` block used to yield ONE scope and ONE node for every type in it, so each type after the first lost its field typeBindings and every field-receiver call in the file emitted nothing. Capture COUNT is unchanged; only ranges moved, plus the new go-grouped-type-decl fixture. Prior c27fb803598581fa4eb7ddf5ef6f8369b9e3a150082d11362e7aa3ec8faaa832 -> e386598526e502d131e52a17d219635b3a4196d94f1ebdd25922a2582c985d18; scaling 1.054 < 1.5.",
|
||||
"_rebaselined_2873_undecided_satisfaction_fixtures": "#2873: added test/fixtures/lang-resolution/go-extern-qualified-signatures/ (5 Go files) and go-undecided-satisfaction/ (1 Go file) as the committed regression fixtures for out-of-repo package qualifiers in method signatures and for a satisfaction check that cannot be decided. Go fixture_count 116 -> 122. Prior e386598526e502d131e52a17d219635b3a4196d94f1ebdd25922a2582c985d18 -> 9c554a9d698a2b79fb419852daadca87b8aae88180cceabf9c8d82f3e3300f2e. FIXTURE-CORPUS GROWTH, NOT A CAPTURE CHANGE: the accompanying fix is resolution-time (signatureContextForFile recovers an identity for unresolvable imports) plus a tri-state verdict, neither of which runs during capture; go was the ONLY language whose fingerprint drifted and every other language matched its baseline on the same run."
|
||||
},
|
||||
"cobol": {
|
||||
"fingerprint": "c8c00b56a7da24e04080eb885714fbbf45e3903324f0cf9df0754f5b5a92e3aa",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,8 @@
|
|||
import type { ParsedFile, ReferenceSite, SymbolDefinition } from 'gitnexus-shared';
|
||||
import type {
|
||||
StructuralImplementationResult,
|
||||
UndecidedSatisfaction,
|
||||
} from '../../scope-resolution/contract/scope-resolver.js';
|
||||
import type { SemanticModel } from '../../model/semantic-model.js';
|
||||
import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js';
|
||||
import { simpleQualifiedName } from '../../scope-resolution/graph-bridge/ids.js';
|
||||
|
|
@ -29,6 +33,23 @@ type EmbeddedParent = { readonly structId: string; readonly asPointer: boolean }
|
|||
type DualMethodSet = { readonly value: MutableMethodSet; readonly pointer: MutableMethodSet };
|
||||
/** Which method set satisfied an interface. `value` implies pointer too. */
|
||||
export type GoReceiverForm = 'value' | 'pointer';
|
||||
/**
|
||||
* Whether a type satisfies an interface — or whether we could not tell.
|
||||
*
|
||||
* `undecided` is the state #2873 was missing. It means a required signature
|
||||
* named something we could not give an identity to (a package qualifier with no
|
||||
* recoverable import path), so the comparison was never actually performed.
|
||||
* Folding it into `unsatisfied` is what let `impact()` answer a confident zero
|
||||
* for a method that in fact had callers.
|
||||
*
|
||||
* It stays distinct from `unsatisfied` in exactly one direction: an undecided
|
||||
* pair mints NO edge (a speculative one would fan out into fabricated CALLS),
|
||||
* but it IS reported, so the answer downstream is a lower bound instead of a
|
||||
* fact. Compare `go/types`, which folds the same case the other way — its
|
||||
* `hasAllMethods` returns true for an invalid type — because a type checker's
|
||||
* job is to avoid cascading errors, not to bound a blast radius.
|
||||
*/
|
||||
type Verdict = 'satisfied' | 'unsatisfied' | 'undecided';
|
||||
/** One structural implementor plus the form in which it implements. */
|
||||
export type GoStructuralImplementor = {
|
||||
readonly structDefId: string;
|
||||
|
|
@ -36,7 +57,12 @@ export type GoStructuralImplementor = {
|
|||
};
|
||||
type SignatureContext = {
|
||||
readonly packageQualifier: string | undefined;
|
||||
readonly importQualifiers: ReadonlyMap<string, string>;
|
||||
/** Every token this file may write before a `.`, mapped to the package it
|
||||
* names. Keyed on the token the SOURCE uses, which is the import's local name
|
||||
* except where that had to be recovered from the path (`…/bar/v2` -> `bar`).
|
||||
* An `undefined` value is a name that is claimed but has no agreeable
|
||||
* qualifier; it reads the same as an absent key at the one consumer. */
|
||||
readonly importQualifiers: ReadonlyMap<string, string | undefined>;
|
||||
};
|
||||
type DetectionIndexes = {
|
||||
readonly interfaces: readonly SymbolDefinition[];
|
||||
|
|
@ -76,7 +102,7 @@ export function detectGoInterfaceImplementations(
|
|||
parsedFiles: readonly ParsedFile[],
|
||||
_indexes: ScopeResolutionIndexes,
|
||||
_model: SemanticModel,
|
||||
): Map<string, GoStructuralImplementor[]> {
|
||||
): StructuralImplementationResult {
|
||||
return detectGoInterfaceImplementationsFromIndexes(buildDetectionIndexes(parsedFiles, _indexes));
|
||||
}
|
||||
|
||||
|
|
@ -457,8 +483,9 @@ function uniqueInterfaceNamed(
|
|||
|
||||
function detectGoInterfaceImplementationsFromIndexes(
|
||||
indexes: DetectionIndexes,
|
||||
): Map<string, GoStructuralImplementor[]> {
|
||||
): StructuralImplementationResult {
|
||||
const implementations = new Map<string, GoStructuralImplementor[]>();
|
||||
const undecided: UndecidedSatisfaction[] = [];
|
||||
const methodSetCache = new Map<string, MutableMethodSet>();
|
||||
for (const iface of indexes.interfaces) {
|
||||
const required = collectInterfaceMethodSet(iface, indexes, new Set(), methodSetCache);
|
||||
|
|
@ -476,31 +503,60 @@ function detectGoInterfaceImplementationsFromIndexes(
|
|||
// additive: every implementor found before #2855 is still found, in the
|
||||
// same order, and instantiation only ever appends.
|
||||
const formByStructId = new Map<string, GoReceiverForm>();
|
||||
const undecidedStructIds = new Set<string>();
|
||||
for (const candidateSet of [required, ...instantiatedMethodSetsFor(iface, required, indexes)]) {
|
||||
for (const structId of candidateStructIds) {
|
||||
if (formByStructId.get(structId) === 'value') continue;
|
||||
const pointerSet = indexes.effectiveMethodsByStructId.get(structId);
|
||||
if (pointerSet === undefined) continue;
|
||||
// MS(*T) is the superset: if it does not satisfy, neither does MS(T).
|
||||
if (!methodSetSatisfies(pointerSet, candidateSet, indexes.signatureContextByDefId))
|
||||
const verdict = methodSetSatisfies(
|
||||
pointerSet,
|
||||
candidateSet,
|
||||
indexes.signatureContextByDefId,
|
||||
);
|
||||
if (verdict !== 'satisfied') {
|
||||
// `undecided` mints no edge — a speculative IMPLEMENTS would fan out
|
||||
// into fabricated CALLS through `emitReceiverBoundCalls`. It is
|
||||
// recorded instead, so `impact` can report a lower bound rather than
|
||||
// a confident zero (#2873). A decided `unsatisfied` records nothing:
|
||||
// that answer is trustworthy.
|
||||
if (verdict === 'undecided') undecidedStructIds.add(structId);
|
||||
continue;
|
||||
}
|
||||
// Then ask the narrower question separately — does the VALUE type satisfy?
|
||||
// This is the distinction `var x I = T{}` turns on, and it is a fact about
|
||||
// the program, not a heuristic.
|
||||
const valueSet = indexes.valueMethodsByStructId.get(structId);
|
||||
const satisfiesByValue =
|
||||
valueSet !== undefined &&
|
||||
methodSetSatisfies(valueSet, candidateSet, indexes.signatureContextByDefId);
|
||||
methodSetSatisfies(valueSet, candidateSet, indexes.signatureContextByDefId) ===
|
||||
'satisfied';
|
||||
formByStructId.set(structId, satisfiesByValue ? 'value' : 'pointer');
|
||||
undecidedStructIds.delete(structId);
|
||||
}
|
||||
}
|
||||
const implementors: GoStructuralImplementor[] = [...formByStructId].map(
|
||||
([structDefId, receiverForm]) => ({ structDefId, receiverForm }),
|
||||
);
|
||||
if (implementors.length > 0) implementations.set(iface.nodeId, implementors);
|
||||
if (undecidedStructIds.size > 0) {
|
||||
const candidateNames: string[] = [];
|
||||
for (const structId of undecidedStructIds) {
|
||||
const name = indexes.structsById.get(structId)?.qualifiedName;
|
||||
if (name !== undefined) candidateNames.push(name);
|
||||
}
|
||||
undecided.push({
|
||||
interfaceDefId: iface.nodeId,
|
||||
interfaceName: iface.qualifiedName,
|
||||
filePath: iface.filePath,
|
||||
undecidedCandidates: undecidedStructIds.size,
|
||||
candidateNames,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return implementations;
|
||||
return { implementations, undecided };
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -1004,36 +1060,54 @@ function methodSetSatisfies(
|
|||
actual: MethodSet,
|
||||
required: MethodSet,
|
||||
signatureContextByDefId: ReadonlyMap<string, SignatureContext>,
|
||||
): boolean {
|
||||
): Verdict {
|
||||
let undecided = false;
|
||||
for (const [name, requiredOverloads] of required) {
|
||||
const actualOverloads = actual.get(name);
|
||||
if (actualOverloads === undefined) return false;
|
||||
if (actualOverloads === undefined) return 'unsatisfied';
|
||||
for (const requiredMethod of requiredOverloads) {
|
||||
// Fast arity pre-filter: if the required method has a known parameter
|
||||
// count, reject immediately when no actual overload matches it. This
|
||||
// avoids the expensive signature normalization loop for obvious mismatches.
|
||||
if (requiredMethod.parameterCount !== undefined) {
|
||||
if (!actualOverloads.some((a) => a.parameterCount === requiredMethod.parameterCount)) {
|
||||
return false;
|
||||
return 'unsatisfied';
|
||||
}
|
||||
}
|
||||
if (!hasCompatibleMethod(actualOverloads, requiredMethod, signatureContextByDefId)) {
|
||||
return false;
|
||||
}
|
||||
const verdict = compatibleMethodVerdict(
|
||||
actualOverloads,
|
||||
requiredMethod,
|
||||
signatureContextByDefId,
|
||||
);
|
||||
// A decided mismatch anywhere ends it — a type that provably lacks ONE
|
||||
// required method does not implement the interface, however many other
|
||||
// methods we could not read. Undecided keeps scanning for exactly that
|
||||
// reason: a hard no may still be waiting, and it is the better answer.
|
||||
if (verdict === 'unsatisfied') return 'unsatisfied';
|
||||
if (verdict === 'undecided') undecided = true;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
return undecided ? 'undecided' : 'satisfied';
|
||||
}
|
||||
|
||||
function hasCompatibleMethod(
|
||||
function compatibleMethodVerdict(
|
||||
actualOverloads: readonly SymbolDefinition[],
|
||||
requiredMethod: SymbolDefinition,
|
||||
signatureContextByDefId: ReadonlyMap<string, SignatureContext>,
|
||||
): boolean {
|
||||
if (!hasVerifiableSignature(requiredMethod)) return false;
|
||||
return actualOverloads.some((actualMethod) =>
|
||||
signaturesCompatible(actualMethod, requiredMethod, signatureContextByDefId),
|
||||
);
|
||||
): Verdict {
|
||||
// Nothing in the interface's own method to compare against: this is missing
|
||||
// information, not a difference. It was a `false` before #2873.
|
||||
if (!hasVerifiableSignature(requiredMethod)) return 'undecided';
|
||||
let undecided = false;
|
||||
for (const actualMethod of actualOverloads) {
|
||||
const verdict = signaturesCompatible(actualMethod, requiredMethod, signatureContextByDefId);
|
||||
// One overload that provably matches settles the method — the unknowns on
|
||||
// the others cannot unsettle it. (Pyright does the same: a resolvable path
|
||||
// suppresses the partially-unknown diagnostic from the others.)
|
||||
if (verdict === 'satisfied') return 'satisfied';
|
||||
if (verdict === 'undecided') undecided = true;
|
||||
}
|
||||
return undecided ? 'undecided' : 'unsatisfied';
|
||||
}
|
||||
|
||||
function methodSetHasVerifiableSignatures(methods: MethodSet): boolean {
|
||||
|
|
@ -1056,61 +1130,115 @@ function signaturesCompatible(
|
|||
actual: SymbolDefinition,
|
||||
required: SymbolDefinition,
|
||||
signatureContextByDefId: ReadonlyMap<string, SignatureContext>,
|
||||
): boolean {
|
||||
): Verdict {
|
||||
const actualContext = signatureContextByDefId.get(actual.nodeId);
|
||||
const requiredContext = signatureContextByDefId.get(required.nodeId);
|
||||
return (
|
||||
countsCompatible(actual.parameterCount, required.parameterCount) &&
|
||||
countsCompatible(actual.requiredParameterCount, required.requiredParameterCount) &&
|
||||
parameterTypesCompatible(
|
||||
actual.parameterTypes,
|
||||
required.parameterTypes,
|
||||
actualContext,
|
||||
requiredContext,
|
||||
) &&
|
||||
returnTypesCompatible(actual.returnType, required.returnType, actualContext, requiredContext)
|
||||
if (
|
||||
!countsCompatible(actual.parameterCount, required.parameterCount) ||
|
||||
!countsCompatible(actual.requiredParameterCount, required.requiredParameterCount)
|
||||
) {
|
||||
return 'unsatisfied';
|
||||
}
|
||||
// A decided mismatch beats an unknown — it is the answer we can stand behind —
|
||||
// so the parameter verdict only short-circuits when it is `unsatisfied`.
|
||||
const parameters = parameterTypesVerdict(actual, required, actualContext, requiredContext);
|
||||
if (parameters === 'unsatisfied') return 'unsatisfied';
|
||||
const returns = returnTypeVerdict(
|
||||
actual.returnType,
|
||||
required.returnType,
|
||||
actualContext,
|
||||
requiredContext,
|
||||
);
|
||||
if (returns === 'unsatisfied') return 'unsatisfied';
|
||||
return parameters === 'undecided' || returns === 'undecided' ? 'undecided' : 'satisfied';
|
||||
}
|
||||
|
||||
function countsCompatible(actual: number | undefined, required: number | undefined): boolean {
|
||||
return actual === undefined || required === undefined || actual === required;
|
||||
}
|
||||
|
||||
function parameterTypesCompatible(
|
||||
actual: readonly string[] | undefined,
|
||||
required: readonly string[] | undefined,
|
||||
function parameterTypesVerdict(
|
||||
actualDef: SymbolDefinition,
|
||||
requiredDef: SymbolDefinition,
|
||||
actualContext: SignatureContext | undefined,
|
||||
requiredContext: SignatureContext | undefined,
|
||||
): boolean {
|
||||
if (actual === undefined || required === undefined) return true;
|
||||
if (actual.length !== required.length) return false;
|
||||
return actual.every((type, index) => {
|
||||
const actualType = normalizeSignatureType(type, actualContext);
|
||||
const requiredType = normalizeSignatureType(required[index]!, requiredContext);
|
||||
return actualType !== undefined && requiredType !== undefined && actualType === requiredType;
|
||||
});
|
||||
): Verdict {
|
||||
const actual = actualDef.parameterTypes;
|
||||
const required = requiredDef.parameterTypes;
|
||||
if (actual === undefined || required === undefined) {
|
||||
// A method that takes nothing has no list to carry — that is a decided
|
||||
// agreement, not a gap, and it is the shape of every `Close() error`.
|
||||
if (actualDef.parameterCount === 0 && requiredDef.parameterCount === 0) return 'satisfied';
|
||||
// Otherwise the types really are unread. This is where the old code assumed
|
||||
// `true` and called two signatures compatible without comparing them.
|
||||
return 'undecided';
|
||||
}
|
||||
if (actual.length !== required.length) return 'unsatisfied';
|
||||
// Indexed loop, not `entries()`: this is the innermost comparison in the
|
||||
// detection pass and runs once per parameter per candidate pair.
|
||||
let undecided = false;
|
||||
for (let index = 0; index < actual.length; index++) {
|
||||
const verdict = typeVerdict(actual[index]!, required[index]!, actualContext, requiredContext);
|
||||
if (verdict === 'unsatisfied') return 'unsatisfied';
|
||||
if (verdict === 'undecided') undecided = true;
|
||||
}
|
||||
return undecided ? 'undecided' : 'satisfied';
|
||||
}
|
||||
|
||||
function returnTypesCompatible(
|
||||
function returnTypeVerdict(
|
||||
actual: string | undefined,
|
||||
required: string | undefined,
|
||||
actualContext: SignatureContext | undefined,
|
||||
requiredContext: SignatureContext | undefined,
|
||||
): boolean {
|
||||
if (required === undefined) return actual === undefined;
|
||||
if (actual === undefined) return false;
|
||||
): Verdict {
|
||||
if (required === undefined) return actual === undefined ? 'satisfied' : 'unsatisfied';
|
||||
if (actual === undefined) return 'unsatisfied';
|
||||
return typeVerdict(actual, required, actualContext, requiredContext);
|
||||
}
|
||||
|
||||
/** The one place a type spelling decides anything, and the only mint site of
|
||||
* `undecided` below the method level: `normalizeSignatureType` returns
|
||||
* `undefined` when a package qualifier has no identity we could recover, and
|
||||
* two spellings we could not normalize are not thereby different. */
|
||||
function typeVerdict(
|
||||
actual: string,
|
||||
required: string,
|
||||
actualContext: SignatureContext | undefined,
|
||||
requiredContext: SignatureContext | undefined,
|
||||
): Verdict {
|
||||
const actualType = normalizeSignatureType(actual, actualContext);
|
||||
const requiredType = normalizeSignatureType(required, requiredContext);
|
||||
return actualType !== undefined && requiredType !== undefined && actualType === requiredType;
|
||||
if (actualType === undefined || requiredType === undefined) return 'undecided';
|
||||
return actualType === requiredType ? 'satisfied' : 'unsatisfied';
|
||||
}
|
||||
|
||||
/** Normalized form of every type spelling seen in a file, keyed by its context.
|
||||
*
|
||||
* Normalization is a pure function of (spelling, context), and a context is
|
||||
* immutable once built — so this is a cache, not state. It earns its keep
|
||||
* because #2873 removed the early bail: a parameter list that named an
|
||||
* out-of-repo type used to normalize to `undefined` and stop the comparison at
|
||||
* parameter 0, and now every pair of (interface method, candidate struct) walks
|
||||
* its whole signature, re-normalizing both sides once per candidate. */
|
||||
const normalizedTypesByContext = new WeakMap<SignatureContext, Map<string, string | undefined>>();
|
||||
|
||||
function normalizeSignatureType(typeName: string, context?: SignatureContext): string | undefined {
|
||||
// Go type identity includes pointer/slice/map/variadic shape and package
|
||||
// qualifiers. Only erase whitespace and qualify bare local type names; stripping
|
||||
// `*`, `[]`, `...`, or `pkg.` would make non-identical signatures compare equal.
|
||||
const compact = typeName.replace(/\s+/g, '');
|
||||
if (context === undefined) return compact;
|
||||
return qualifyGoSignatureTypes(compact, context);
|
||||
let normalized = normalizedTypesByContext.get(context);
|
||||
if (normalized === undefined) {
|
||||
normalized = new Map();
|
||||
normalizedTypesByContext.set(context, normalized);
|
||||
}
|
||||
// `has`, not a truthiness check: `undefined` — "no agreeable identity" — is
|
||||
// itself a result worth caching, and it is the one this file mints most.
|
||||
if (normalized.has(compact)) return normalized.get(compact);
|
||||
const qualified = qualifyGoSignatureTypes(compact, context);
|
||||
normalized.set(compact, qualified);
|
||||
return qualified;
|
||||
}
|
||||
|
||||
function qualifyGoSignatureTypes(typeName: string, context: SignatureContext): string | undefined {
|
||||
|
|
@ -1138,12 +1266,30 @@ function signatureContextForFile(
|
|||
parsed: ParsedFile,
|
||||
indexes: ScopeResolutionIndexes,
|
||||
): SignatureContext {
|
||||
const importQualifiers = new Map<string, string>();
|
||||
// A key present with an `undefined` value means "in-repo, but no directory to
|
||||
// name it by" — a repo-ROOT package, whose own file spells its types bare, so
|
||||
// no qualifier either side can agree on exists. It still has to occupy the
|
||||
// name, or the fallback below would label a repo package external.
|
||||
const importQualifiers = new Map<string, string | undefined>();
|
||||
const importEdges = indexes.imports?.get(parsed.moduleScope) ?? [];
|
||||
for (const edge of importEdges) {
|
||||
if (edge.kind !== 'namespace' || edge.targetFile === null) continue;
|
||||
const qualifier = packageQualifierForFile(edge.targetFile);
|
||||
if (qualifier !== undefined) importQualifiers.set(edge.localName, qualifier);
|
||||
importQualifiers.set(edge.localName, packageQualifierForFile(edge.targetFile));
|
||||
}
|
||||
// An import that resolves to no file in the repository — every stdlib and
|
||||
// third-party package — still has an identity: its import path, which the
|
||||
// parsed directive kept even though the finalized `ImportEdge` did not (#2873).
|
||||
// Without this fallback `ctx context.Context` normalized to `undefined`, and
|
||||
// `undefined` reads as "signatures differ" on both sides at once, so two
|
||||
// textually identical methods compared unequal and Go interface satisfaction
|
||||
// only ever succeeded for builtin-only signatures.
|
||||
for (const directive of parsed.parsedImports) {
|
||||
if (directive.kind !== 'namespace') continue;
|
||||
const token = goImportToken(directive.localName, directive.targetRaw);
|
||||
// The edges ran first, so a token an in-repo import already claimed keeps
|
||||
// its package directory — the fallback fills gaps, it does not compete.
|
||||
if (importQualifiers.has(token)) continue;
|
||||
importQualifiers.set(token, externalPackageQualifier(directive.targetRaw));
|
||||
}
|
||||
return {
|
||||
packageQualifier: packageQualifierForFile(parsed.filePath),
|
||||
|
|
@ -1151,6 +1297,39 @@ function signatureContextForFile(
|
|||
};
|
||||
}
|
||||
|
||||
/** Identity for a package that lives outside the repository.
|
||||
*
|
||||
* The import path is the exact identity — `net/http` and `example.com/x/http`
|
||||
* are different packages that both spell their qualifier `http`, so keying on
|
||||
* the local name would make them compare equal. The prefix keeps the result in
|
||||
* a namespace no in-repo qualifier can reach: no package directory can begin
|
||||
* with the literal `extern:`. */
|
||||
function externalPackageQualifier(importPath: string): string {
|
||||
return `extern:${importPath}`;
|
||||
}
|
||||
|
||||
/** The token Go source writes before the `.` for this import.
|
||||
*
|
||||
* An alias names its own token, so it is returned as-is. An unaliased import
|
||||
* arrives here spelled as the last path segment, which is right until a module
|
||||
* carries a major version: `github.com/foo/bar/v2` is written `bar` and
|
||||
* `gopkg.in/yaml.v3` is written `yaml`, per the rule the go tool applies.
|
||||
*
|
||||
* Deriving "aliased" from the path rather than from `importedName` is
|
||||
* deliberate — the Go extractor sets both names to the alias when there is one
|
||||
* (`import-decomposer.ts`), so the two fields never disagree.
|
||||
*
|
||||
* A package whose name diverges from its path for any OTHER reason cannot be
|
||||
* recovered without reading the dependency's own source, which is by definition
|
||||
* outside the repository. Those stay unresolved, which is the safe direction. */
|
||||
function goImportToken(localName: string, importPath: string): string {
|
||||
const segments = importPath.split('/').filter((segment) => segment.length > 0);
|
||||
const leaf = segments.pop() ?? importPath;
|
||||
if (localName !== leaf) return localName;
|
||||
const name = /^v\d+$/.test(leaf) ? (segments.pop() ?? leaf) : leaf;
|
||||
return name.replace(/\.v\d+$/, '');
|
||||
}
|
||||
|
||||
/** The package directory, or `undefined` for a repo-root file.
|
||||
*
|
||||
* Shares `goPackageDir` with the package-clause resolver rather than repeating
|
||||
|
|
|
|||
|
|
@ -379,6 +379,7 @@ export const runPipelineFromRepo = async (
|
|||
let processResult: ProcessesOutput['processResult'] | undefined;
|
||||
const scopeResolutionOutput = getPhaseOutput<ScopeResolutionOutput>(results, 'scopeResolution');
|
||||
const resolutionOutcomes = scopeResolutionOutput.resolutionOutcomes;
|
||||
const undecidedSatisfaction = scopeResolutionOutput.undecidedSatisfaction;
|
||||
// Streamed PDG-emit manifest (#2202): present only when streaming was on.
|
||||
const pdgEmitManifest = scopeResolutionOutput.pdgEmitManifest;
|
||||
const propertyInference = scopeResolutionOutput.propertyInference;
|
||||
|
|
@ -421,6 +422,7 @@ export const runPipelineFromRepo = async (
|
|||
communityResult,
|
||||
processResult,
|
||||
resolutionOutcomes,
|
||||
undecidedSatisfaction,
|
||||
usedWorkerPool,
|
||||
pdgEmitManifest,
|
||||
propertyInference,
|
||||
|
|
|
|||
|
|
@ -337,6 +337,41 @@ export interface StructuralImplementor {
|
|||
readonly receiverForm: 'value' | 'pointer';
|
||||
}
|
||||
|
||||
/**
|
||||
* One interface whose satisfaction check could not be COMPLETED for at least
|
||||
* one candidate type — not one that was checked and came out negative.
|
||||
*
|
||||
* The distinction is the whole point (#2873): a detector that reports only
|
||||
* positives makes "nobody implements this" and "we could not tell whether
|
||||
* anybody implements this" byte-identical, and the second one silently becomes
|
||||
* a confident zero in `impact`. Consumers must not turn these into edges; they
|
||||
* exist so a query can say it is answering with a lower bound.
|
||||
*/
|
||||
export interface UndecidedSatisfaction {
|
||||
readonly interfaceDefId: string;
|
||||
readonly interfaceName: string;
|
||||
readonly filePath: string;
|
||||
/** How many candidate types went unjudged for this interface. */
|
||||
readonly undecidedCandidates: number;
|
||||
/**
|
||||
* The candidate types themselves, by name.
|
||||
*
|
||||
* Both sides are recorded because a query arrives from either one. Asking
|
||||
* `impact` about the IMPLEMENTATION — the case #2873 reports — never touches
|
||||
* the interface node at all: the walk starts at a method whose owner has no
|
||||
* heritage edge precisely because the check was undecided, so an
|
||||
* interface-keyed record alone would leave that query unhedged.
|
||||
*/
|
||||
readonly candidateNames: readonly string[];
|
||||
}
|
||||
|
||||
/** What `detectInterfaceImplementations` answers: the positives, plus the
|
||||
* questions it could not answer. */
|
||||
export interface StructuralImplementationResult {
|
||||
readonly implementations: Map<string, readonly StructuralImplementor[]>;
|
||||
readonly undecided: readonly UndecidedSatisfaction[];
|
||||
}
|
||||
|
||||
export interface ScopeResolver {
|
||||
/** Identity for telemetry + per-language flag check. */
|
||||
readonly language: SupportedLanguages;
|
||||
|
|
@ -1302,7 +1337,7 @@ export interface ScopeResolver {
|
|||
parsedFiles: readonly ParsedFile[],
|
||||
indexes: ScopeResolutionIndexes,
|
||||
model: SemanticModel,
|
||||
) => Map<string, readonly StructuralImplementor[]>;
|
||||
) => StructuralImplementationResult;
|
||||
|
||||
/**
|
||||
* Optional: mirror typeBindings from namespace-import target modules
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ import {
|
|||
forceGc,
|
||||
} from '../../../../storage/parsedfile-store.js';
|
||||
import type { ResolutionOutcome } from '../resolution-outcome.js';
|
||||
import type { UndecidedSatisfaction } from '../contract/scope-resolver.js';
|
||||
import type { FunctionSummary } from '../../taint/summary-model.js';
|
||||
import type { CallSummary } from '../../taint/call-summary-model.js';
|
||||
import { buildFunctionNodeIndex } from '../../taint/summary-harvest-driver.js';
|
||||
|
|
@ -62,6 +63,14 @@ export interface ScopeResolutionOutput {
|
|||
readonly referenceEdgesEmitted: number;
|
||||
/** Additive stream of resolver diagnostics; does not affect graph edges. */
|
||||
readonly resolutionOutcomes: readonly ResolutionOutcome[];
|
||||
/**
|
||||
* Interfaces whose structural-satisfaction check could not be completed
|
||||
* (#2873). Emits no edges — it is what lets a query distinguish "nothing
|
||||
* implements this" from "we could not tell what implements this".
|
||||
*
|
||||
* Absent when no language ran; `[]` when one ran and decided everything.
|
||||
*/
|
||||
readonly undecidedSatisfaction?: readonly UndecidedSatisfaction[];
|
||||
/**
|
||||
* Property inference facts a CALLER needs in order to read an empty result
|
||||
* correctly (R3-1). Without these, "no ACCESSES for this field" is
|
||||
|
|
@ -117,6 +126,7 @@ const NOOP_OUTPUT: ScopeResolutionOutput = Object.freeze({
|
|||
importsEmitted: 0,
|
||||
referenceEdgesEmitted: 0,
|
||||
resolutionOutcomes: [],
|
||||
// Deliberately absent, not `[]`: nothing ran, so nothing was decided either.
|
||||
perLanguage: new Map(),
|
||||
functionSummaries: [],
|
||||
callSummaries: [],
|
||||
|
|
@ -209,6 +219,7 @@ export const scopeResolutionPhase: PipelinePhase<ScopeResolutionOutput> = {
|
|||
let totalRefs = 0;
|
||||
let anyRan = false;
|
||||
const resolutionOutcomes: ResolutionOutcome[] = [];
|
||||
const undecidedSatisfaction: UndecidedSatisfaction[] = [];
|
||||
// M4 (#2084 U1): per-function taint summaries accumulated across every
|
||||
// language pass; the cross-function fixpoint phase reads this output.
|
||||
const functionSummaries: FunctionSummary[] = [];
|
||||
|
|
@ -556,6 +567,7 @@ export const scopeResolutionPhase: PipelinePhase<ScopeResolutionOutput> = {
|
|||
processedScopeFiles += langFileCount;
|
||||
anyRan = true;
|
||||
functionSummaries.push(...stats.functionSummaries);
|
||||
undecidedSatisfaction.push(...stats.undecidedSatisfaction);
|
||||
callSummaries.push(...stats.callSummaries);
|
||||
totalFiles += stats.filesProcessed;
|
||||
totalImports += stats.importsEmitted;
|
||||
|
|
@ -647,6 +659,7 @@ export const scopeResolutionPhase: PipelinePhase<ScopeResolutionOutput> = {
|
|||
importsEmitted: totalImports,
|
||||
referenceEdgesEmitted: totalRefs,
|
||||
resolutionOutcomes,
|
||||
undecidedSatisfaction,
|
||||
perLanguage,
|
||||
functionSummaries,
|
||||
callSummaries,
|
||||
|
|
|
|||
|
|
@ -93,7 +93,7 @@ import {
|
|||
collectDeferredIndirectSites,
|
||||
emitCallableValueFlow,
|
||||
} from '../passes/callable-value-flow.js';
|
||||
import type { ScopeResolver } from '../contract/scope-resolver.js';
|
||||
import type { ScopeResolver, UndecidedSatisfaction } from '../contract/scope-resolver.js';
|
||||
import { findEnclosingClassDef, resolveInheritanceBaseInScope } from '../scope/walkers.js';
|
||||
import { buildWorkspaceResolutionIndex } from '../workspace-index.js';
|
||||
import type { ResolutionOutcome, ResolutionOutcomeRecorder } from '../resolution-outcome.js';
|
||||
|
|
@ -231,8 +231,8 @@ function emitDetectedInterfaceImplementations(
|
|||
provider: ScopeResolver,
|
||||
indexes: ReturnType<typeof finalizeScopeModel>,
|
||||
model: SemanticModel,
|
||||
): number {
|
||||
if (provider.detectInterfaceImplementations === undefined) return 0;
|
||||
): readonly UndecidedSatisfaction[] {
|
||||
if (provider.detectInterfaceImplementations === undefined) return [];
|
||||
|
||||
const graphIdByDefId = new Map<string, string>();
|
||||
for (const parsed of parsedFiles) {
|
||||
|
|
@ -248,9 +248,8 @@ function emitDetectedInterfaceImplementations(
|
|||
existing.add(`${rel.sourceId}->${rel.targetId}`);
|
||||
}
|
||||
|
||||
let emitted = 0;
|
||||
const detected = provider.detectInterfaceImplementations(parsedFiles, indexes, model);
|
||||
for (const [interfaceDefId, implementorDefIds] of detected) {
|
||||
for (const [interfaceDefId, implementorDefIds] of detected.implementations) {
|
||||
const targetId = graphIdByDefId.get(interfaceDefId);
|
||||
if (targetId === undefined) continue;
|
||||
for (const implementor of implementorDefIds) {
|
||||
|
|
@ -277,11 +276,13 @@ function emitDetectedInterfaceImplementations(
|
|||
? `${provider.language}-structural-implements-pointer`
|
||||
: `${provider.language}-structural-implements`,
|
||||
});
|
||||
emitted++;
|
||||
}
|
||||
}
|
||||
|
||||
return emitted;
|
||||
// The interfaces this provider could not decide. They mint no edges — they
|
||||
// exist so a query can report a lower bound instead of a confident zero
|
||||
// (#2873); see `undecided-satisfaction.ts`.
|
||||
return detected.undecided;
|
||||
}
|
||||
|
||||
export type ScopeResolutionSubPhase =
|
||||
|
|
@ -490,6 +491,14 @@ interface RunScopeResolutionStats {
|
|||
readonly languages: string[];
|
||||
}[];
|
||||
readonly resolutionOutcomes: readonly ResolutionOutcome[];
|
||||
/**
|
||||
* Interfaces whose structural-satisfaction check could not be completed for
|
||||
* at least one candidate type (#2873). NOT the same as "no implementors" —
|
||||
* these are questions the analyzer could not answer, and they are reported so
|
||||
* `impact` can hedge a zero instead of asserting one. Empty for every
|
||||
* language whose resolver has no `detectInterfaceImplementations` hook.
|
||||
*/
|
||||
readonly undecidedSatisfaction: readonly UndecidedSatisfaction[];
|
||||
/**
|
||||
* Per-function taint summaries harvested in the pdg window (#2084 M4 U1).
|
||||
* Empty unless `input.pdg === true` and the language has a registered taint
|
||||
|
|
@ -516,6 +525,7 @@ export function runScopeResolution(
|
|||
const callableFlowOnly = provider.scopeResolutionEdgeMode === 'callable-flow-only';
|
||||
const onWarn = input.onWarn ?? (() => {});
|
||||
const resolutionOutcomes: ResolutionOutcome[] = [];
|
||||
const undecidedSatisfaction: UndecidedSatisfaction[] = [];
|
||||
const recordResolutionOutcome: ResolutionOutcomeRecorder = (outcome) => {
|
||||
resolutionOutcomes.push(outcome);
|
||||
input.recordResolutionOutcome?.(outcome);
|
||||
|
|
@ -625,6 +635,7 @@ export function runScopeResolution(
|
|||
uniqueNamePropertyCrossLanguage: 0,
|
||||
uniqueNamePropertyCrossLanguageNames: [],
|
||||
resolutionOutcomes,
|
||||
undecidedSatisfaction,
|
||||
functionSummaries: [],
|
||||
callSummaries: [],
|
||||
};
|
||||
|
|
@ -661,6 +672,7 @@ export function runScopeResolution(
|
|||
uniqueNamePropertyCrossLanguage: 0,
|
||||
uniqueNamePropertyCrossLanguageNames: [],
|
||||
resolutionOutcomes,
|
||||
undecidedSatisfaction,
|
||||
functionSummaries: [],
|
||||
callSummaries: [],
|
||||
};
|
||||
|
|
@ -721,13 +733,15 @@ export function runScopeResolution(
|
|||
? buildGraphNodeLookup(graph)
|
||||
: nodeLookup;
|
||||
if (!callableFlowOnly) {
|
||||
emitDetectedInterfaceImplementations(
|
||||
graph,
|
||||
parsedFiles,
|
||||
postHeritageNodeLookup,
|
||||
provider,
|
||||
finalized,
|
||||
readonlyModel,
|
||||
undecidedSatisfaction.push(
|
||||
...emitDetectedInterfaceImplementations(
|
||||
graph,
|
||||
parsedFiles,
|
||||
postHeritageNodeLookup,
|
||||
provider,
|
||||
finalized,
|
||||
readonlyModel,
|
||||
),
|
||||
);
|
||||
}
|
||||
const mroByClassDefId = provider.buildMro(graph, parsedFiles, postHeritageNodeLookup);
|
||||
|
|
@ -1603,6 +1617,7 @@ export function runScopeResolution(
|
|||
uniqueNamePropertyCrossLanguage: uniqueNameProperties.crossLanguageOnly,
|
||||
uniqueNamePropertyCrossLanguageNames: uniqueNameProperties.crossLanguageOnlyNames,
|
||||
resolutionOutcomes,
|
||||
undecidedSatisfaction,
|
||||
functionSummaries: harvestedSummaries,
|
||||
callSummaries: harvestedCallSummaries,
|
||||
};
|
||||
|
|
|
|||
57
gitnexus/src/core/ingestion/scope-resolution/summary-maps.ts
Normal file
57
gitnexus/src/core/ingestion/scope-resolution/summary-maps.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
/**
|
||||
* Shared shape for the name→count maps this directory persists into `RepoMeta`
|
||||
* (`UnresolvedReceiverSummary`, `UndecidedSatisfactionSummary`).
|
||||
*
|
||||
* Both are samples of an analysis-time fact, both are capped, and both are read
|
||||
* back by `impact` to hedge an answer. The ranking and the lookup therefore
|
||||
* have to behave identically across them — two hand-copied comparators that
|
||||
* must stay in step is exactly the drift these summaries cannot tolerate, and a
|
||||
* lookup that is prototype-safe in one artifact and not the other is a
|
||||
* user-facing bug waiting on whichever map is read first.
|
||||
*/
|
||||
import { compareCodeUnits } from '../../../lib/utils.js';
|
||||
|
||||
/**
|
||||
* Rank a name→count map highest-first and cap it.
|
||||
*
|
||||
* `compareCodeUnits`, not `localeCompare` (#2787). The tiebreak feeds the
|
||||
* `.slice()` below, so locale-sensitive collation would decide WHICH entries
|
||||
* survive the cap, not merely how they are listed — and ICU order varies by
|
||||
* platform and ICU build, so two runs over one repo could persist different
|
||||
* sets. Key-based lookup is unaffected either way.
|
||||
*
|
||||
* `omitted` is the number of distinct names past the cap, so the caller can
|
||||
* report truncation rather than silently losing entries.
|
||||
*/
|
||||
export function rankAndCap(
|
||||
counts: ReadonlyMap<string, number>,
|
||||
cap: number,
|
||||
): { kept: [string, number][]; omitted: number } {
|
||||
const ranked = [...counts.entries()].sort(
|
||||
([aName, aCount], [bName, bCount]) => bCount - aCount || compareCodeUnits(aName, bName),
|
||||
);
|
||||
const kept = ranked.slice(0, cap);
|
||||
return { kept, omitted: ranked.length - kept.length };
|
||||
}
|
||||
|
||||
/**
|
||||
* Read one count out of a persisted map, prototype-safely.
|
||||
*
|
||||
* The map is revived from JSON, so a bare `counts[name]` returns a Function for
|
||||
* `constructor` / `toString` / `valueOf` — all ordinary member and type names in
|
||||
* a code graph — and a Function compares as neither absent nor a number, which
|
||||
* is how one of these once interpolated a function into user-facing text.
|
||||
*
|
||||
* Returns `undefined` when the name was never recorded, and only ever a finite
|
||||
* positive number otherwise.
|
||||
*/
|
||||
export function lookupCount(
|
||||
counts: Readonly<Record<string, number>> | undefined,
|
||||
name: string,
|
||||
): number | undefined {
|
||||
if (counts === undefined || name.length === 0) return undefined;
|
||||
if (!Object.hasOwn(counts, name)) return undefined;
|
||||
const count = counts[name];
|
||||
if (typeof count !== 'number' || !Number.isFinite(count) || count <= 0) return undefined;
|
||||
return count;
|
||||
}
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
/**
|
||||
* Summarize interfaces whose structural-satisfaction check could not be
|
||||
* COMPLETED, for persistence in `RepoMeta` (#2873).
|
||||
*
|
||||
* The distinction this exists to preserve: an interface with no implementors in
|
||||
* the graph and an interface whose implementors could not be decided are
|
||||
* byte-identical at query time — both are zero edges. Only the first is an
|
||||
* answer. Without this record, `impact()` on a method reachable solely through
|
||||
* the second reports zero callers and calls that result `exact`.
|
||||
*
|
||||
* It rides in `RepoMeta`, not the graph, for the same reason
|
||||
* `UnresolvedReceiverSummary` does: the fact is about the ANALYSIS rather than
|
||||
* about the code, so it belongs beside the analysis, and a relationship
|
||||
* property would move `SCHEMA_FINGERPRINT` and force a full re-analyze. It also
|
||||
* keeps the marker invisible to every IMPLEMENTS consumer — MRO,
|
||||
* METHOD_IMPLEMENTS derivation, dispatch fan-out, clustering, DI — none of
|
||||
* which should see a question as an edge.
|
||||
*/
|
||||
import type { UndecidedSatisfaction } from './contract/scope-resolver.js';
|
||||
import { rankAndCap } from './summary-maps.js';
|
||||
|
||||
/** Twin of `MAX_UNRESOLVED_RECEIVER_MEMBERS`, same rationale. Truncation is
|
||||
* reported, never silent — see `totalInterfaces` / `omittedCandidates`. */
|
||||
export const MAX_UNDECIDED_INTERFACES = 500;
|
||||
|
||||
export interface UndecidedSatisfactionSummary {
|
||||
/**
|
||||
* Interface name → how many candidate types went unjudged for it. Capped at
|
||||
* {@link MAX_UNDECIDED_INTERFACES} entries, highest count first.
|
||||
*
|
||||
* Keyed by NAME rather than by node id because the query side matches against
|
||||
* the interface names a boundary walk already has in hand, and because a node
|
||||
* id is only meaningful against the exact index that minted it.
|
||||
*/
|
||||
readonly counts: Readonly<Record<string, number>>;
|
||||
/** Distinct interfaces that could not be fully decided, including any beyond
|
||||
* the cap. Always the true total, so a consumer can tell `counts` is a
|
||||
* sample rather than the whole. */
|
||||
readonly totalInterfaces: number;
|
||||
/** Total unjudged (interface, candidate) pairs, including beyond the cap. */
|
||||
readonly totalCandidates: number;
|
||||
/**
|
||||
* Candidate type name → how many interfaces went unjudged FOR that type.
|
||||
*
|
||||
* The other side of the same fact, and the side the reported symptom needs:
|
||||
* `impact` on an implementation method never reaches the interface node —
|
||||
* the heritage edge that would take it there is exactly what went missing —
|
||||
* so an interface-keyed record alone leaves that query confidently wrong.
|
||||
* Capped like `counts`, highest first.
|
||||
*/
|
||||
readonly candidateCounts: Readonly<Record<string, number>>;
|
||||
/** Distinct candidate types beyond the cap. Absent when none were dropped. */
|
||||
readonly omittedCandidates?: number;
|
||||
}
|
||||
|
||||
/** Returns `undefined` when nothing was undecided: absence means "this run
|
||||
* decided everything it looked at", which must stay distinguishable from a
|
||||
* zeroed record at read time. */
|
||||
export function summarizeUndecidedSatisfaction(
|
||||
undecided: readonly UndecidedSatisfaction[],
|
||||
): UndecidedSatisfactionSummary | undefined {
|
||||
if (undecided.length === 0) return undefined;
|
||||
|
||||
const byName = new Map<string, number>();
|
||||
const byCandidate = new Map<string, number>();
|
||||
let totalCandidates = 0;
|
||||
for (const entry of undecided) {
|
||||
totalCandidates += entry.undecidedCandidates;
|
||||
byName.set(
|
||||
entry.interfaceName,
|
||||
(byName.get(entry.interfaceName) ?? 0) + entry.undecidedCandidates,
|
||||
);
|
||||
for (const candidate of entry.candidateNames) {
|
||||
byCandidate.set(candidate, (byCandidate.get(candidate) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Highest count first, name as tiebreak. The tiebreak is load-bearing, not
|
||||
// cosmetic: past the cap it decides WHICH entries survive, and a
|
||||
// locale-sensitive comparison would make the persisted file depend on the
|
||||
// machine that wrote it.
|
||||
const ranked = rankAndCap(byName, MAX_UNDECIDED_INTERFACES);
|
||||
const candidates = rankAndCap(byCandidate, MAX_UNDECIDED_INTERFACES);
|
||||
|
||||
return {
|
||||
counts: Object.fromEntries(ranked.kept),
|
||||
// `totalInterfaces` minus the kept keys IS the omitted count, so only the
|
||||
// total is persisted — unlike the sibling summary, which has no total to
|
||||
// derive it from and therefore carries the omission explicitly.
|
||||
totalInterfaces: ranked.kept.length + ranked.omitted,
|
||||
totalCandidates,
|
||||
candidateCounts: Object.fromEntries(candidates.kept),
|
||||
...(candidates.omitted > 0 ? { omittedCandidates: candidates.omitted } : {}),
|
||||
};
|
||||
}
|
||||
|
|
@ -14,7 +14,7 @@
|
|||
*/
|
||||
|
||||
import { createLogger } from '../../logger.js';
|
||||
import { compareCodeUnits } from '../../../lib/utils.js';
|
||||
import { rankAndCap, lookupCount } from './summary-maps.js';
|
||||
|
||||
import type { ResolutionOutcome } from './resolution-outcome.js';
|
||||
|
||||
|
|
@ -72,30 +72,7 @@ export interface UnresolvedReceiverSummary {
|
|||
* every analyze for no behavioural reason. ONE comparator, shared by the
|
||||
* in-program and external maps: two hand-copied comparators that must stay
|
||||
* identical or the artifact churns on one map and not the other is exactly the
|
||||
* drift this contract cannot tolerate.
|
||||
*
|
||||
* `omitted` is the number of distinct names past the cap, so the caller can
|
||||
* report truncation rather than silently losing entries.
|
||||
*/
|
||||
function rankAndCap(
|
||||
counts: Map<string, number>,
|
||||
cap: number = MAX_UNRESOLVED_RECEIVER_MEMBERS,
|
||||
): {
|
||||
kept: [string, number][];
|
||||
omitted: number;
|
||||
} {
|
||||
const ranked = [...counts.entries()].sort(
|
||||
// `compareCodeUnits`, not `localeCompare` (#2787). The tiebreak feeds the
|
||||
// `.slice()` below, so locale-sensitive collation would decide WHICH
|
||||
// entries survive the cap, not merely how they are listed — and ICU order
|
||||
// varies by platform and ICU build, so two runs over one repo could persist
|
||||
// different sets. Key-based lookup is unaffected either way.
|
||||
([aName, aCount], [bName, bCount]) => bCount - aCount || compareCodeUnits(aName, bName),
|
||||
);
|
||||
const kept = ranked.slice(0, cap);
|
||||
return { kept, omitted: ranked.length - kept.length };
|
||||
}
|
||||
|
||||
/**
|
||||
* A `receiver-unresolved` drop at a CALL site.
|
||||
*
|
||||
|
|
@ -167,8 +144,11 @@ export function summarizeUnresolvedReceivers(
|
|||
// "nothing was lost" is distinguishable from "nothing was measured".
|
||||
if (totalSites === 0 && externalSites === 0) return undefined;
|
||||
|
||||
const { kept, omitted: omittedNames } = rankAndCap(counts);
|
||||
const { kept: externalKept, omitted: externalOmittedNames } = rankAndCap(externalCounts);
|
||||
const { kept, omitted: omittedNames } = rankAndCap(counts, MAX_UNRESOLVED_RECEIVER_MEMBERS);
|
||||
const { kept: externalKept, omitted: externalOmittedNames } = rankAndCap(
|
||||
externalCounts,
|
||||
MAX_UNRESOLVED_RECEIVER_MEMBERS,
|
||||
);
|
||||
|
||||
return {
|
||||
counts: Object.fromEntries(kept),
|
||||
|
|
@ -202,12 +182,7 @@ export function lookupUnresolvedCallCount(
|
|||
summary: UnresolvedReceiverSummary | undefined,
|
||||
symName: string,
|
||||
): number | undefined {
|
||||
const counts = summary?.counts;
|
||||
if (counts === undefined || symName.length === 0) return undefined;
|
||||
if (!Object.hasOwn(counts, symName)) return undefined;
|
||||
const sites = counts[symName];
|
||||
if (typeof sites !== 'number' || !Number.isFinite(sites) || sites <= 0) return undefined;
|
||||
return sites;
|
||||
return lookupCount(summary?.counts, symName);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -221,12 +196,7 @@ export function lookupExternalCallCount(
|
|||
summary: UnresolvedReceiverSummary | undefined,
|
||||
symName: string,
|
||||
): number | undefined {
|
||||
const counts = summary?.externalCounts;
|
||||
if (counts === undefined || symName.length === 0) return undefined;
|
||||
if (!Object.hasOwn(counts, symName)) return undefined;
|
||||
const sites = counts[symName];
|
||||
if (typeof sites !== 'number' || !Number.isFinite(sites) || sites <= 0) return undefined;
|
||||
return sites;
|
||||
return lookupCount(summary?.externalCounts, symName);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import {
|
|||
logUnresolvedReceiverFiles,
|
||||
summarizeUnresolvedReceivers,
|
||||
} from './ingestion/scope-resolution/unresolved-receivers.js';
|
||||
import { summarizeUndecidedSatisfaction } from './ingestion/scope-resolution/undecided-satisfaction.js';
|
||||
import type { KnowledgeGraph } from './graph/types.js';
|
||||
import { resetDegradedParseCounter } from './tree-sitter/safe-parse.js';
|
||||
import {
|
||||
|
|
@ -3541,6 +3542,16 @@ async function runFullAnalysisInner(
|
|||
// Git-only: non-git repos never take the incremental path.
|
||||
schemaFingerprint: hasGitDir(repoPath) ? SCHEMA_FINGERPRINT : undefined,
|
||||
unresolvedReceiverMembers: summarizeUnresolvedReceivers(resolutionOutcomes),
|
||||
// Carried forward ONLY when this run could not measure — `saveMeta` writes
|
||||
// a fresh object, so omitting the key deletes a prior record and turns a
|
||||
// hedged answer back into a confident one. A run that DID measure always
|
||||
// wins, including when it measured nothing: vendoring the missing
|
||||
// dependency and re-analyzing has to be able to clear the hedge, or the
|
||||
// field becomes permanent noise and readers learn to ignore it.
|
||||
undecidedInterfaceSatisfaction:
|
||||
pipelineResult.undecidedSatisfaction === undefined
|
||||
? existingMeta?.undecidedInterfaceSatisfaction
|
||||
: summarizeUndecidedSatisfaction(pipelineResult.undecidedSatisfaction),
|
||||
analysisFeatures: currentAnalysisFeatures,
|
||||
// Always stamped with the live resolved mode (#2331/#2339) — unlike
|
||||
// `pdg` below, 'none' is a meaningful value to compare, not an
|
||||
|
|
|
|||
|
|
@ -114,6 +114,9 @@ import {
|
|||
lookupExternalCallCount,
|
||||
lookupUnresolvedCallCount,
|
||||
} from '../../core/ingestion/scope-resolution/unresolved-receivers.js';
|
||||
import type { UnresolvedReceiverSummary } from '../../core/ingestion/scope-resolution/unresolved-receivers.js';
|
||||
import type { UndecidedSatisfactionSummary } from '../../core/ingestion/scope-resolution/undecided-satisfaction.js';
|
||||
import { lookupCount } from '../../core/ingestion/scope-resolution/summary-maps.js';
|
||||
import {
|
||||
fnLineOf,
|
||||
isPdgDegradedLayerStatus,
|
||||
|
|
@ -675,9 +678,30 @@ export interface EpistemicCauses {
|
|||
* Unit: call sites — same unit and same source as `receiverTyping`.
|
||||
*/
|
||||
readonly externalBoundary: number;
|
||||
/**
|
||||
* Interface-satisfaction checks the ANALYZER could not complete, on a
|
||||
* boundary this query crossed (#2873). Unit: unjudged (interface, candidate
|
||||
* type) pairs.
|
||||
*
|
||||
* Distinct from every slot above, which count facts the analyzer decided and
|
||||
* then could not attribute. This one counts questions it never answered — a
|
||||
* type in a required signature had no identity to compare, so no IMPLEMENTS
|
||||
* edge was minted and no dispatch boundary exists for the walk to notice. It
|
||||
* is the one cause that makes a result short WITHOUT leaving a trace in the
|
||||
* graph, which is why it has to be read from the index metadata instead.
|
||||
*
|
||||
* Zero on any index written before the field existed; that reads the same as
|
||||
* "nothing was undecided", and a re-index is what tells the two apart.
|
||||
*/
|
||||
readonly undecidedSatisfaction: number;
|
||||
}
|
||||
|
||||
function epistemicFrom(dropped: { notes: readonly string[]; sites: number; external: number }): {
|
||||
function epistemicFrom(dropped: {
|
||||
notes: readonly string[];
|
||||
sites: number;
|
||||
external: number;
|
||||
undecided: number;
|
||||
}): {
|
||||
epistemic: 'exact' | 'lower-bound';
|
||||
boundaries?: string[];
|
||||
causes?: EpistemicCauses;
|
||||
|
|
@ -689,7 +713,12 @@ function epistemicFrom(dropped: { notes: readonly string[]; sites: number; exter
|
|||
? dropped.external > 0
|
||||
? {
|
||||
epistemic: 'exact',
|
||||
causes: { receiverTyping: 0, dispatchBoundary: 0, externalBoundary: dropped.external },
|
||||
causes: {
|
||||
receiverTyping: 0,
|
||||
dispatchBoundary: 0,
|
||||
externalBoundary: dropped.external,
|
||||
undecidedSatisfaction: 0,
|
||||
},
|
||||
}
|
||||
: { epistemic: 'exact' }
|
||||
: {
|
||||
|
|
@ -703,10 +732,83 @@ function epistemicFrom(dropped: { notes: readonly string[]; sites: number; exter
|
|||
receiverTyping: dropped.sites,
|
||||
dispatchBoundary: 0,
|
||||
externalBoundary: dropped.external,
|
||||
undecidedSatisfaction: dropped.undecided,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Boundary notes for call sites the analyzer dropped because it could not type
|
||||
* their receiver, when the queried symbol's name is among them (#2744).
|
||||
*
|
||||
* Empty when the index records no drops for this name — including every index
|
||||
* written before the summary existed, which is why the schema version was
|
||||
* bumped rather than treating "absent" as "none".
|
||||
*/
|
||||
function unresolvedReceiverBoundaries(
|
||||
summary: UnresolvedReceiverSummary | undefined,
|
||||
symName: string,
|
||||
): { notes: string[]; sites: number; external: number } {
|
||||
if (symName.length === 0) return { notes: [], sites: 0, external: 0 };
|
||||
const sites = lookupUnresolvedCallCount(summary, symName);
|
||||
const external = lookupExternalCallCount(summary, symName) ?? 0;
|
||||
if (sites === undefined) return { notes: [], sites: 0, external };
|
||||
return {
|
||||
notes: [
|
||||
`${sites} call ${sites === 1 ? 'site' : 'sites'} invoking \`${symName}\` ${
|
||||
sites === 1 ? 'was' : 'were'
|
||||
} dropped at index time because the receiver's type could not be ` +
|
||||
`established (e.g. an unresolved constructor, factory or chained ` +
|
||||
`expression). Those callers are absent from this result — actual ` +
|
||||
`impact may be higher.`,
|
||||
],
|
||||
sites,
|
||||
external,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Boundary notes for interface-satisfaction checks the analyzer could not
|
||||
* COMPLETE, when the queried symbol is on either side of one (#2873).
|
||||
*
|
||||
* Matched against both maps because a query arrives from either direction: on
|
||||
* the interface itself, or on a candidate implementation — the reported case,
|
||||
* and the one no graph probe can find, because the edge that would lead there
|
||||
* is precisely what went missing. See `undecided-satisfaction.ts`.
|
||||
*/
|
||||
function undecidedSatisfactionBoundaries(
|
||||
summary: UndecidedSatisfactionSummary,
|
||||
names: readonly string[],
|
||||
): { notes: string[]; undecided: number } {
|
||||
const notes: string[] = [];
|
||||
let undecided = 0;
|
||||
for (const name of names) {
|
||||
const asInterface = lookupCount(summary.counts, name) ?? 0;
|
||||
if (asInterface > 0) {
|
||||
undecided += asInterface;
|
||||
notes.push(
|
||||
`\`${name}\` is an interface whose implementors could not be fully determined at ` +
|
||||
`index time: ${asInterface} candidate ${asInterface === 1 ? 'type was' : 'types were'} ` +
|
||||
`left unjudged because a type in a required signature could not be resolved. ` +
|
||||
`Implementations are missing from this result — actual impact may be higher.`,
|
||||
);
|
||||
}
|
||||
const asCandidate = lookupCount(summary.candidateCounts, name) ?? 0;
|
||||
if (asCandidate > 0) {
|
||||
undecided += asCandidate;
|
||||
const one = asCandidate === 1;
|
||||
notes.push(
|
||||
`\`${name}\` was a candidate implementation for ${asCandidate} ` +
|
||||
`${one ? 'interface' : 'interfaces'} the analyzer could not decide, so no ` +
|
||||
`IMPLEMENTS edge was recorded and callers dispatching through ` +
|
||||
`${one ? 'that interface' : 'those interfaces'} are absent from this result — ` +
|
||||
`actual impact may be higher.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return { notes, undecided };
|
||||
}
|
||||
|
||||
interface RepoHandle {
|
||||
id: string; // unique key = repo name (basename)
|
||||
name: string;
|
||||
|
|
@ -6410,7 +6512,44 @@ export class LocalBackend {
|
|||
// reason #2708 was filed. A dropped site's callee is unknown, so the index
|
||||
// records the member NAME invoked at the drop; a match on the queried
|
||||
// symbol's name means at least one call to something of that name was lost.
|
||||
const droppedBoundaries = await this.unresolvedReceiverBoundaries(repo, symName);
|
||||
// ONE read of the index metadata for both probes below. They are the second
|
||||
// and third consumers of this file on a path whose own comments call out
|
||||
// avoiding a per-call `loadMeta` (see `ensureInitialized`), and the file is
|
||||
// dominated by `fileHashes` — megabytes on a large repo.
|
||||
// `try`, not `.catch`: `loadMeta` can throw synchronously (a stubbed module
|
||||
// in tests, a mid-read unmount), and a probe failing must never read as
|
||||
// certainty — the whole point of this function.
|
||||
let meta: Awaited<ReturnType<typeof loadMeta>> | undefined;
|
||||
try {
|
||||
meta = await loadMeta(path.dirname(repo.lbugPath));
|
||||
} catch {
|
||||
meta = undefined;
|
||||
}
|
||||
const receiverDrops = unresolvedReceiverBoundaries(meta?.unresolvedReceiverMembers, symName);
|
||||
// #2873 — satisfaction checks the analyzer never completed. Read on the
|
||||
// same footing as the receiver drops, and BEFORE the heritage probe for the
|
||||
// same reason: this cause leaves no edge for that probe to find, so a
|
||||
// graph-only answer is exactly the confident zero being fixed.
|
||||
//
|
||||
// Gated on the record existing: without it the answer cannot change, and
|
||||
// the owning-type hop below would be a graph round-trip per method query in
|
||||
// every index that has no such record — which is every non-Go one, since Go
|
||||
// is the only language with a structural-satisfaction hook.
|
||||
const undecidedSummary = meta?.undecidedInterfaceSatisfaction;
|
||||
const undecidedDrops =
|
||||
undecidedSummary === undefined
|
||||
? { notes: [], undecided: 0 }
|
||||
: undecidedSatisfactionBoundaries(undecidedSummary, [
|
||||
symName,
|
||||
...(symType === 'Method' || symType === 'Function'
|
||||
? await this.owningTypeNames(repo, symId)
|
||||
: []),
|
||||
]);
|
||||
const droppedBoundaries = {
|
||||
...receiverDrops,
|
||||
notes: [...receiverDrops.notes, ...undecidedDrops.notes],
|
||||
undecided: undecidedDrops.undecided,
|
||||
};
|
||||
try {
|
||||
// Discover the interface / abstract supertypes on the target's boundary.
|
||||
// If the target is itself an interface, it is its own boundary node.
|
||||
|
|
@ -6507,6 +6646,7 @@ export class LocalBackend {
|
|||
receiverTyping: droppedBoundaries.sites,
|
||||
dispatchBoundary: dispatchBoundarySymbols,
|
||||
externalBoundary: droppedBoundaries.external,
|
||||
undecidedSatisfaction: droppedBoundaries.undecided,
|
||||
},
|
||||
};
|
||||
} catch {
|
||||
|
|
@ -6516,39 +6656,22 @@ export class LocalBackend {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Boundary notes for call sites the analyzer dropped because it could not
|
||||
* type their receiver, when the queried symbol's name is among them (#2744).
|
||||
* Empty when the index records no drops for this name — including every
|
||||
* index written before the summary existed, which is why the schema version
|
||||
* was bumped rather than treating "absent" as "none".
|
||||
*/
|
||||
private async unresolvedReceiverBoundaries(
|
||||
repo: RepoHandle,
|
||||
symName: string,
|
||||
): Promise<{ notes: string[]; sites: number; external: number }> {
|
||||
if (symName.length === 0) return { notes: [], sites: 0, external: 0 };
|
||||
try {
|
||||
const meta = await loadMeta(path.dirname(repo.lbugPath));
|
||||
const summary = meta?.unresolvedReceiverMembers;
|
||||
// Prototype-safe: see `lookupUnresolvedCallCount`. A bare `counts[symName]`
|
||||
// returns a Function for `constructor`/`toString`/… and `NaN <= 0` is false,
|
||||
// so the old guard let it through into user-facing text.
|
||||
const sites = lookupUnresolvedCallCount(summary, symName);
|
||||
const external = lookupExternalCallCount(summary, symName) ?? 0;
|
||||
if (sites === undefined) return { notes: [], sites: 0, external };
|
||||
const notes = [
|
||||
`${sites} call ${sites === 1 ? 'site' : 'sites'} invoking \`${symName}\` ${
|
||||
sites === 1 ? 'was' : 'were'
|
||||
} dropped at index time because the receiver's type could not be ` +
|
||||
`established (e.g. an unresolved constructor, factory or chained ` +
|
||||
`expression). Those callers are absent from this result — actual ` +
|
||||
`impact may be higher.`,
|
||||
];
|
||||
return { notes, sites, external };
|
||||
} catch {
|
||||
return { notes: [], sites: 0, external: 0 };
|
||||
}
|
||||
/** Declaring types of a method, for matching against a candidate-keyed
|
||||
* record. One hop, asked only for methods, and only when a record exists to
|
||||
* match against. */
|
||||
private async owningTypeNames(repo: RepoHandle, symId: string): Promise<string[]> {
|
||||
const rows = await executeParameterized(
|
||||
repo.lbugPath,
|
||||
`MATCH (owner)-[r:CodeRelation]->(m)
|
||||
WHERE m.id = $symId AND r.type = 'HAS_METHOD'
|
||||
RETURN DISTINCT owner.name AS name
|
||||
ORDER BY name
|
||||
LIMIT 8`,
|
||||
{ symId },
|
||||
).catch(() => []);
|
||||
return rows
|
||||
.map((r: any) => (r.name ?? r[0] ?? '') as string)
|
||||
.filter((n: string) => n.length > 0);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -287,7 +287,7 @@ NOTE: ACCESSES edges (field read/write tracking) are included in context results
|
|||
COMPLETENESS OF incoming: alongside symbol/incoming/outgoing the result carries the same epistemic envelope impact() returns:
|
||||
- epistemic: 'exact' | 'lower-bound' — 'lower-bound' means callers exist that this view provably does not list.
|
||||
- boundaries: string[] — one plain-language sentence per reason. Prose for humans; branch on causes instead.
|
||||
- causes: { receiverTyping, dispatchBoundary, externalBoundary } — machine-readable WHY. Every field counts MISSING THINGS, never sentences:
|
||||
- causes: { receiverTyping, dispatchBoundary, externalBoundary, undecidedSatisfaction } — machine-readable WHY. Every field counts MISSING THINGS, never sentences:
|
||||
- causes.receiverTyping (unit: call sites) > 0 — RESOLVER GAP: the analyzer dropped that many call sites on this name because it could not type the receiver, so they are missing from incoming. Do not read an absent caller as proof none exists.
|
||||
- causes.externalBoundary (unit: call sites) > 0 — the calls left the indexed program (System.out.println, fetch(...)). NOT a defect: no in-graph node could have been reached. An epistemic:'exact' result can carry this.
|
||||
- causes.dispatchBoundary (unit: symbols) > 0 — DI / interface dispatch: implementations plus interface-level consumers behind a boundary static analysis cannot cross. Irreducible.
|
||||
|
|
@ -463,12 +463,14 @@ Output includes:
|
|||
- byDepth: affected symbols grouped by traversal depth (paginated by limit/offset; omitted when summaryOnly:true — use byDepthCounts for totals per depth, pagination object when truncated). Each item includes a processes:[{id,label,processType,step}] field listing the execution flows that symbol participates in. Empty when the symbol has no process membership. Can ALSO be empty when partial:true is set — either the process-aggregation pass hit its cap before detecting affected processes, or per-symbol enrichment was capped on a very large page. When partial:true, do NOT treat processes:[] as proof of no participation; cross-check the top-level affected_processes list.
|
||||
- epistemic: 'exact' | 'lower-bound' — whether impactedCount is the whole story. 'lower-bound' means the walk provably missed callers, so the count is a floor. Absent only on skipped probes (ambiguous-candidate lists, group fan-out).
|
||||
- boundaries: string[] — one plain-language sentence per reason the count is short. Prose for humans; branch on causes instead.
|
||||
- causes: { receiverTyping, dispatchBoundary, externalBoundary } — the machine-readable split of WHY, so an agent gating its own edits can tell a fixable analyzer gap from an irreducible one. Every field counts MISSING THINGS, never sentences:
|
||||
- causes: { receiverTyping, dispatchBoundary, externalBoundary, undecidedSatisfaction } — the machine-readable split of WHY, so an agent gating its own edits can tell a fixable analyzer gap from an irreducible one. Every field counts MISSING THINGS, never sentences:
|
||||
- causes.receiverTyping (unit: call sites) > 0 — the RESOLVER GAP signal: the analyzer dropped that many call sites because it could not establish the receiver's type (unresolved constructor, factory, chained expression). Those callers are absent from byDepth. Treat the result as incomplete: grep the symbol name before deleting or renaming.
|
||||
- causes.externalBoundary (unit: call sites) > 0 — those calls left the indexed program (System.out.println, fetch(...), os.environ.*). NOT a defect and NOT a reason the count is short: there is no in-graph node any edge could have reached. An epistemic:'exact' result can carry this.
|
||||
- causes.dispatchBoundary (unit: symbols) > 0 — DI / interface dispatch: that many implementations plus interface-level consumers sit on the far side of a boundary a static walk cannot cross. Irreducible; a compiler refuses here too. A symbol count, not a site count — per-site multiplicity is not retained for these edges — so compare its magnitude with receiverTyping, not its exact value.
|
||||
|
||||
REQUIRES RE-INDEX: causes.receiverTyping and causes.externalBoundary are read from index-time metadata that only a current analyzer writes. Against an older index they read as absent/0, which is indistinguishable from "nothing was dropped" — re-run \`gitnexus analyze\` before trusting a zero there.
|
||||
- causes.undecidedSatisfaction (unit: unjudged interface/type pairs) > 0 — the analyzer could not DECIDE whether a type satisfies an interface (a type in a required signature named a package it could not resolve), so no IMPLEMENTS edge exists and no dispatch boundary was left for the walk to notice. Distinct from every cause above, which count decided facts that could not be attributed; this one counts questions never answered. It is the only cause that shortens a result WITHOUT leaving a trace in the graph, so an unhedged zero on a symbol reached only through such an interface would otherwise read as 'nobody calls this'. Usually fixable: it most often means a dependency is missing from the analyzed tree.
|
||||
|
||||
REQUIRES RE-INDEX: causes.receiverTyping, causes.externalBoundary and causes.undecidedSatisfaction are read from index-time metadata that only a current analyzer writes. Against an older index they read as absent/0, which is indistinguishable from "nothing was dropped" — re-run \`gitnexus analyze\` before trusting a zero there.
|
||||
|
||||
Depth groups:
|
||||
- d=1: WILL BREAK (direct callers/importers)
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import { stripWindowsLongPathPrefix } from '../lib/utils.js';
|
|||
import { writeFileAtomic } from './fs-atomic.js';
|
||||
import { logger } from '../core/logger.js';
|
||||
import type { UnresolvedReceiverSummary } from '../core/ingestion/scope-resolution/unresolved-receivers.js';
|
||||
import type { UndecidedSatisfactionSummary } from '../core/ingestion/scope-resolution/undecided-satisfaction.js';
|
||||
import { acquireIndexLock, IndexLockTimeoutError, type IndexLockHandle } from './index-lock.js';
|
||||
import {
|
||||
branchSlug,
|
||||
|
|
@ -320,6 +321,22 @@ export interface RepoMeta {
|
|||
* this adds no runtime dependency from storage/ on core/.
|
||||
*/
|
||||
unresolvedReceiverMembers?: UnresolvedReceiverSummary;
|
||||
/**
|
||||
* Interfaces whose structural-satisfaction check this run could not COMPLETE
|
||||
* (#2873) — not interfaces found to have no implementors.
|
||||
*
|
||||
* Read by `impact()` to report `epistemic: 'lower-bound'` instead of
|
||||
* `'exact'` when a walk crosses one of these interfaces. Without it, an
|
||||
* interface whose implementors were never decided is byte-identical to one
|
||||
* that genuinely has none: both are zero IMPLEMENTS edges, and only the
|
||||
* second is an answer.
|
||||
*
|
||||
* Absent when a run decided everything it looked at, which is the common case
|
||||
* and keeps `epistemic` exact for cleanly-resolving repos. Absence is NOT the
|
||||
* same as a zeroed record — an index written before this field existed also
|
||||
* reads as absent, and both correctly mean "no hedge available from here".
|
||||
*/
|
||||
undecidedInterfaceSatisfaction?: UndecidedSatisfactionSummary;
|
||||
/**
|
||||
* SHA-256 of every file's content at the time of the last successful
|
||||
* indexing run. The next run computes current hashes and diffs against
|
||||
|
|
|
|||
|
|
@ -2,6 +2,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';
|
||||
import type { UndecidedSatisfaction } from '../core/ingestion/scope-resolution/contract/scope-resolver.js';
|
||||
import type { PdgEmitManifest } from '../core/lbug/pdg-emit-sink.js';
|
||||
import type { GraphEmitManifest } from '../core/lbug/graph-emit-sink.js';
|
||||
|
||||
|
|
@ -20,6 +21,16 @@ export interface PipelineResult {
|
|||
* produced; graph edge semantics are unchanged.
|
||||
*/
|
||||
resolutionOutcomes: readonly ResolutionOutcome[];
|
||||
/**
|
||||
* Interfaces whose structural-satisfaction check could not be completed
|
||||
* (#2873). Empty for languages with no structural detection.
|
||||
*
|
||||
* ABSENT means scope resolution never ran, which is not the same claim as an
|
||||
* empty array — that one says the analyzer looked and decided everything.
|
||||
* The writer needs the difference: it carries a prior record forward across a
|
||||
* run that could not measure, and CLEARS it on a run that measured clean.
|
||||
*/
|
||||
undecidedSatisfaction?: readonly UndecidedSatisfaction[];
|
||||
/**
|
||||
* True if a worker pool was actually constructed for this run. The worker
|
||||
* pool is the sole parse path (sequential parsing was removed). False means
|
||||
|
|
|
|||
|
|
@ -103,6 +103,26 @@
|
|||
"captureGroups": 33,
|
||||
"digest": "9bbba66d384a47803aef95ef963224bbb80838340cdfb67560f2af5e81aa637a"
|
||||
},
|
||||
"go-extern-qualified-signatures/alpha/alpha.go": {
|
||||
"captureGroups": 5,
|
||||
"digest": "1cd2e9e3bab3d04a54fd34a64d88ee68cd89a70a5b89e7657cba72025fbb2021"
|
||||
},
|
||||
"go-extern-qualified-signatures/app/app.go": {
|
||||
"captureGroups": 21,
|
||||
"digest": "5638e8a84cb862359620c8c5a1078e08e355d1a85883d6b166f5fab12bb33370"
|
||||
},
|
||||
"go-extern-qualified-signatures/beta/beta.go": {
|
||||
"captureGroups": 11,
|
||||
"digest": "af2c5772cad79b3a88c86764290f8806fb9a6e8fc12e4c22095eabdc2555b5a6"
|
||||
},
|
||||
"go-extern-qualified-signatures/memory/memory.go": {
|
||||
"captureGroups": 47,
|
||||
"digest": "f079bb9c78b2e38473d0fb7e88ad7b3017abdb5716405ac69929ee40afd5e316"
|
||||
},
|
||||
"go-extern-qualified-signatures/store/store.go": {
|
||||
"captureGroups": 8,
|
||||
"digest": "6b5ed1163020250ec52ae6b67cad03c68a5247971fc10c7b6281821434badfe3"
|
||||
},
|
||||
"go-field-types/cmd/main.go": {
|
||||
"captureGroups": 10,
|
||||
"digest": "dfe8fb6cff7e28cc209ad1237b28e11ee25ca34e89d7101dfa3a69de3186cac9"
|
||||
|
|
@ -447,6 +467,10 @@
|
|||
"captureGroups": 18,
|
||||
"digest": "6e894e96dc0287118d6543bf7c7bcd6c0ae87ad5d1b812e612364c5e4465d340"
|
||||
},
|
||||
"go-undecided-satisfaction/repo.go": {
|
||||
"captureGroups": 24,
|
||||
"digest": "f06022a5a1cfe7bfa03cd86e21135028a83ad99331e8b9af01635d29d6a2a51b"
|
||||
},
|
||||
"go-variadic-resolution/cmd/main.go": {
|
||||
"captureGroups": 6,
|
||||
"digest": "443f9736b9c67df30fe97d8353ddc2584de4c88ba698e62878683d5d77362d89"
|
||||
|
|
|
|||
9
gitnexus/test/fixtures/lang-resolution/go-extern-qualified-signatures/alpha/alpha.go
vendored
Normal file
9
gitnexus/test/fixtures/lang-resolution/go-extern-qualified-signatures/alpha/alpha.go
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
package alpha
|
||||
|
||||
import "example.com/alpha-vendor/client"
|
||||
|
||||
// `client` here and `client` in package beta are DIFFERENT out-of-repo packages
|
||||
// that happen to share a last path segment.
|
||||
type Dialer interface {
|
||||
Dial(cfg client.Config) error
|
||||
}
|
||||
15
gitnexus/test/fixtures/lang-resolution/go-extern-qualified-signatures/app/app.go
vendored
Normal file
15
gitnexus/test/fixtures/lang-resolution/go-extern-qualified-signatures/app/app.go
vendored
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"example.com/extqual/store"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
store store.Store
|
||||
}
|
||||
|
||||
func (h *Handler) Remove(ctx context.Context, id string) error {
|
||||
return h.store.Delete(ctx, id)
|
||||
}
|
||||
7
gitnexus/test/fixtures/lang-resolution/go-extern-qualified-signatures/beta/beta.go
vendored
Normal file
7
gitnexus/test/fixtures/lang-resolution/go-extern-qualified-signatures/beta/beta.go
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
package beta
|
||||
|
||||
import "example.com/beta-vendor/client"
|
||||
|
||||
type BetaDialer struct{}
|
||||
|
||||
func (b *BetaDialer) Dial(cfg client.Config) error { return nil }
|
||||
3
gitnexus/test/fixtures/lang-resolution/go-extern-qualified-signatures/go.mod
vendored
Normal file
3
gitnexus/test/fixtures/lang-resolution/go-extern-qualified-signatures/go.mod
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
module example.com/extqual
|
||||
|
||||
go 1.22
|
||||
24
gitnexus/test/fixtures/lang-resolution/go-extern-qualified-signatures/memory/memory.go
vendored
Normal file
24
gitnexus/test/fixtures/lang-resolution/go-extern-qualified-signatures/memory/memory.go
vendored
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/foo/bar/v2"
|
||||
)
|
||||
|
||||
type Mem struct{}
|
||||
|
||||
func (m *Mem) Delete(ctx context.Context, id string) error { return nil }
|
||||
|
||||
func (m *Mem) Ctx() (context.Context, error) { return nil, nil }
|
||||
|
||||
func (m *Mem) Configure(cfg bar.Config) error { return nil }
|
||||
|
||||
// Same method names, incompatible signatures: still not an implementor.
|
||||
type Wrong struct{}
|
||||
|
||||
func (w *Wrong) Delete(id string) error { return nil }
|
||||
|
||||
func (w *Wrong) Ctx() (context.Context, error) { return nil, nil }
|
||||
|
||||
func (w *Wrong) Configure(cfg bar.Config) error { return nil }
|
||||
17
gitnexus/test/fixtures/lang-resolution/go-extern-qualified-signatures/store/store.go
vendored
Normal file
17
gitnexus/test/fixtures/lang-resolution/go-extern-qualified-signatures/store/store.go
vendored
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/foo/bar/v2"
|
||||
)
|
||||
|
||||
// Every method here names a type from OUTSIDE the repository. Before #2873 that
|
||||
// alone was enough to make structural satisfaction fail.
|
||||
type Store interface {
|
||||
Delete(ctx context.Context, id string) error
|
||||
Ctx() (context.Context, error)
|
||||
// Imported as `bar`, not `v2`: the local name Go uses for a v2+ module is
|
||||
// the segment BEFORE the major version.
|
||||
Configure(cfg bar.Config) error
|
||||
}
|
||||
3
gitnexus/test/fixtures/lang-resolution/go-undecided-satisfaction/go.mod
vendored
Normal file
3
gitnexus/test/fixtures/lang-resolution/go-undecided-satisfaction/go.mod
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
module example.com/undecided
|
||||
|
||||
go 1.22
|
||||
23
gitnexus/test/fixtures/lang-resolution/go-undecided-satisfaction/repo.go
vendored
Normal file
23
gitnexus/test/fixtures/lang-resolution/go-undecided-satisfaction/repo.go
vendored
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
package undecided
|
||||
|
||||
// A DOT import contributes no qualifier — Go puts the imported names straight
|
||||
// into this file's scope, so `shapes.Point` names a package the analyzer has no
|
||||
// identity for. The satisfaction check below cannot be performed at all.
|
||||
import . "example.com/vendor/shapes"
|
||||
|
||||
type Drawer interface {
|
||||
Draw(p shapes.Point) error
|
||||
}
|
||||
|
||||
type Canvas struct{}
|
||||
|
||||
func (c *Canvas) Draw(p shapes.Point) error { return nil }
|
||||
|
||||
// Control: decided in the same file, so the record must name Drawer only.
|
||||
type Named interface {
|
||||
Name() string
|
||||
}
|
||||
|
||||
type Label struct{}
|
||||
|
||||
func (l *Label) Name() string { return "" }
|
||||
|
|
@ -590,6 +590,7 @@ function generateSyntheticInterfaceData(interfaceCount: number, structCount: num
|
|||
language: 'go',
|
||||
scopes: [],
|
||||
imports: [],
|
||||
parsedImports: [],
|
||||
localDefs: defs,
|
||||
referenceSites: [],
|
||||
},
|
||||
|
|
@ -623,8 +624,8 @@ describe('Go structural interface detection O(n²) regression tripwire', () => {
|
|||
const elapsedMs = Date.now() - start;
|
||||
|
||||
// Sanity: each interface should be implemented by all STRUCT_COUNT structs
|
||||
expect(result.size).toBe(IFACE_COUNT);
|
||||
for (const [, impls] of result) {
|
||||
expect(result.implementations.size).toBe(IFACE_COUNT);
|
||||
for (const [, impls] of result.implementations) {
|
||||
expect(impls).toHaveLength(STRUCT_COUNT);
|
||||
}
|
||||
// Regression guard
|
||||
|
|
@ -678,7 +679,7 @@ describe.skipIf(!BENCH_ENABLED)('Go structural interface detection benchmark', (
|
|||
if (elapsed < bestMs) {
|
||||
bestMs = elapsed;
|
||||
implEdges = 0;
|
||||
for (const [, impls] of result) implEdges += impls.length;
|
||||
for (const [, impls] of result.implementations) implEdges += impls.length;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -761,7 +762,7 @@ describe.skipIf(!BENCH_ENABLED)('Go structural interface detection split-phase b
|
|||
if (elapsed < bestTotal) {
|
||||
bestTotal = elapsed;
|
||||
bestImplEdges = 0;
|
||||
for (const [, impls] of result) bestImplEdges += impls.length;
|
||||
for (const [, impls] of result.implementations) bestImplEdges += impls.length;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
133
gitnexus/test/integration/impact-undecided-satisfaction.test.ts
Normal file
133
gitnexus/test/integration/impact-undecided-satisfaction.test.ts
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
/**
|
||||
* Integration test: undecided interface satisfaction reaches `impact` (#2873)
|
||||
*
|
||||
* The failure this pins is the one the issue reports, and it is invisible to a
|
||||
* graph-only probe. When the analyzer cannot DECIDE whether a type satisfies an
|
||||
* interface — a type in a required signature named a package with no
|
||||
* recoverable identity — it mints no IMPLEMENTS edge. The dispatch boundary
|
||||
* that `computeEpistemicBoundary` looks for is derived FROM that edge, so there
|
||||
* is nothing left for it to notice: `impact()` on the implementation reports
|
||||
* zero callers and calls the answer `exact`. The hedge is strongest where it is
|
||||
* least needed and silent where the answer is wrong.
|
||||
*
|
||||
* The graph below is therefore deliberately edge-free between `CtxStoreImpl`
|
||||
* and `CtxStore` — that absence IS the bug — and the only thing that can rescue
|
||||
* the query is the analyzer's own record of what it could not decide, read from
|
||||
* the index metadata.
|
||||
*/
|
||||
import { it, expect, beforeAll, vi } from 'vitest';
|
||||
import path from 'node:path';
|
||||
import { LocalBackend } from '../../src/mcp/local/local-backend.js';
|
||||
import { withTestLbugDB } from '../helpers/test-indexed-db.js';
|
||||
|
||||
vi.mock('../../src/storage/repo-manager.js', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../src/storage/repo-manager.js')>();
|
||||
return {
|
||||
...actual,
|
||||
// Spied, not stubbed: the read count per query is the invariant below.
|
||||
loadMeta: vi.fn(actual.loadMeta),
|
||||
listRegisteredRepos: vi.fn().mockResolvedValue([]),
|
||||
cleanupOldKuzuFiles: vi.fn().mockResolvedValue({ found: false, needsReindex: false }),
|
||||
findSiblingClones: vi.fn().mockResolvedValue([]),
|
||||
};
|
||||
});
|
||||
const { listRegisteredRepos, loadMeta, saveMeta } =
|
||||
await import('../../src/storage/repo-manager.js');
|
||||
|
||||
const SEED = [
|
||||
// The interface, its would-be implementor, and the implementor's method.
|
||||
// NOTE: no IMPLEMENTS edge — the analyzer could not decide, so none exists.
|
||||
`CREATE (iface:Interface {id: 'Interface:store/store.go:CtxStore', name: 'CtxStore', filePath: 'store/store.go', startLine: 1, endLine: 5, isExported: true, content: '', description: ''})`,
|
||||
`CREATE (impl:Struct {id: 'Struct:memory/memory.go:CtxStoreImpl', name: 'CtxStoreImpl', filePath: 'memory/memory.go', startLine: 1, endLine: 20, content: '', description: ''})`,
|
||||
`CREATE (m:Method {id: 'Method:memory/memory.go:CtxStoreImpl.Delete', name: 'Delete', filePath: 'memory/memory.go', startLine: 8, endLine: 10, isExported: true, content: '', description: ''})`,
|
||||
`MATCH (a:Struct {id:'Struct:memory/memory.go:CtxStoreImpl'}), (b:Method {id:'Method:memory/memory.go:CtxStoreImpl.Delete'}) CREATE (a)-[:CodeRelation {type:'HAS_METHOD', confidence:1.0, reason:'method', step:0}]->(b)`,
|
||||
|
||||
// A genuinely isolated leaf in the same index: it must stay `exact`, or the
|
||||
// hedge is just noise applied to everything.
|
||||
`CREATE (leaf:Function {id: 'Function:util/util.go:FormatDate', name: 'FormatDate', filePath: 'util/util.go', startLine: 1, endLine: 3, isExported: true, content: '', description: ''})`,
|
||||
];
|
||||
|
||||
withTestLbugDB(
|
||||
'impact-undecided-satisfaction',
|
||||
(handle) => {
|
||||
let backend: LocalBackend;
|
||||
beforeAll(() => {
|
||||
backend = (handle as any)._backend;
|
||||
});
|
||||
|
||||
it('reports a lower bound for the implementation the analyzer could not judge', async () => {
|
||||
const result: any = await backend.callTool('impact', {
|
||||
target: 'Delete',
|
||||
direction: 'upstream',
|
||||
});
|
||||
expect(result).not.toHaveProperty('error');
|
||||
// The count is still zero — this fix does not invent callers.
|
||||
expect(result.impactedCount).toBe(0);
|
||||
// …but the answer no longer claims to be complete.
|
||||
expect(result.epistemic).toBe('lower-bound');
|
||||
expect(result.causes.undecidedSatisfaction).toBe(1);
|
||||
expect(result.boundaries.join(' ')).toContain('CtxStoreImpl');
|
||||
});
|
||||
|
||||
it('reports a lower bound when asked about the interface itself', async () => {
|
||||
const result: any = await backend.callTool('impact', {
|
||||
target: 'CtxStore',
|
||||
direction: 'upstream',
|
||||
});
|
||||
expect(result).not.toHaveProperty('error');
|
||||
expect(result.epistemic).toBe('lower-bound');
|
||||
expect(result.causes.undecidedSatisfaction).toBe(2);
|
||||
expect(result.boundaries.join(' ')).toContain('could not be fully determined');
|
||||
});
|
||||
|
||||
// Two independent probes read this record, and the file is dominated by
|
||||
// `fileHashes` — megabytes on a real repo. They must share one read.
|
||||
it('reads the index metadata at most once per query', async () => {
|
||||
vi.mocked(loadMeta).mockClear();
|
||||
await backend.callTool('impact', { target: 'Delete', direction: 'upstream' });
|
||||
expect(vi.mocked(loadMeta).mock.calls.length).toBeLessThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('leaves a symbol the analyzer decided cleanly as exact', async () => {
|
||||
const result: any = await backend.callTool('impact', {
|
||||
target: 'FormatDate',
|
||||
direction: 'upstream',
|
||||
});
|
||||
expect(result).not.toHaveProperty('error');
|
||||
expect(result.epistemic).toBe('exact');
|
||||
});
|
||||
},
|
||||
{
|
||||
seed: SEED,
|
||||
poolAdapter: true,
|
||||
afterSetup: async (h) => {
|
||||
// The record the analyzer would have written. `CtxStore` had 2 candidate
|
||||
// types it could not judge; `CtxStoreImpl` was a candidate for 1
|
||||
// interface — the two sides of the same undecided pair set.
|
||||
// `saveMeta`, not a hand-rolled write: it is the only writer production
|
||||
// uses, and it is atomic and dual-writes the legacy mirror. Writing the
|
||||
// file directly would pin a shape no real analyze can produce.
|
||||
await saveMeta(path.dirname(h.dbPath), {
|
||||
undecidedInterfaceSatisfaction: {
|
||||
counts: { CtxStore: 2 },
|
||||
totalInterfaces: 1,
|
||||
totalCandidates: 2,
|
||||
candidateCounts: { CtxStoreImpl: 1 },
|
||||
},
|
||||
} as any);
|
||||
vi.mocked(listRegisteredRepos).mockResolvedValue([
|
||||
{
|
||||
name: 'test-repo',
|
||||
path: '/test/repo',
|
||||
storagePath: h.tmpHandle.dbPath,
|
||||
indexedAt: new Date().toISOString(),
|
||||
lastCommit: 'abc123',
|
||||
stats: { files: 4, nodes: 4, communities: 0, processes: 0 },
|
||||
},
|
||||
] as any);
|
||||
const backend = new LocalBackend();
|
||||
await backend.init();
|
||||
(h as any)._backend = backend;
|
||||
},
|
||||
},
|
||||
);
|
||||
|
|
@ -14,6 +14,16 @@ import {
|
|||
type PipelineResult,
|
||||
} from './helpers.js';
|
||||
|
||||
/** The struct or interface that declares `methodId`, via its HAS_METHOD edge. */
|
||||
function owningTypeName(result: PipelineResult, methodId: string): string {
|
||||
for (const rel of result.graph.iterRelationshipsByType('HAS_METHOD')) {
|
||||
if (rel.targetId !== methodId) continue;
|
||||
const owner = result.graph.getNode(rel.sourceId);
|
||||
return (owner?.properties.name ?? rel.sourceId) as string;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Heritage: package imports + cross-package calls (exercises PackageMap)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -345,15 +355,6 @@ describe('Go structural interface dispatch', () => {
|
|||
);
|
||||
}, 60000);
|
||||
|
||||
function owningTypeName(methodId: string): string {
|
||||
for (const rel of result.graph.iterRelationshipsByType('HAS_METHOD')) {
|
||||
if (rel.targetId !== methodId) continue;
|
||||
const owner = result.graph.getNode(rel.sourceId);
|
||||
return (owner?.properties.name ?? rel.sourceId) as string;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
it('emits signature-checked structural IMPLEMENTS edges only for valid implementors', () => {
|
||||
const implementsEdges = getRelationships(result, 'IMPLEMENTS').filter((edge) =>
|
||||
(edge.rel.reason ?? '').startsWith('go-structural-implements'),
|
||||
|
|
@ -378,7 +379,9 @@ describe('Go structural interface dispatch', () => {
|
|||
const methodEdges = getRelationships(result, 'METHOD_IMPLEMENTS').filter(
|
||||
(edge) => edge.target === 'Save',
|
||||
);
|
||||
const sourceOwners = methodEdges.map((edge) => owningTypeName(edge.rel.sourceId)).sort();
|
||||
const sourceOwners = methodEdges
|
||||
.map((edge) => owningTypeName(result, edge.rel.sourceId))
|
||||
.sort();
|
||||
expect(sourceOwners).toEqual(['MemoryRepository', 'SqlRepository']);
|
||||
});
|
||||
|
||||
|
|
@ -386,7 +389,7 @@ describe('Go structural interface dispatch', () => {
|
|||
const saveCalls = getRelationships(result, 'CALLS').filter(
|
||||
(edge) => edge.source === 'precise' && edge.target === 'Save',
|
||||
);
|
||||
const targetOwners = saveCalls.map((edge) => owningTypeName(edge.rel.targetId));
|
||||
const targetOwners = saveCalls.map((edge) => owningTypeName(result, edge.rel.targetId));
|
||||
expect(targetOwners).toEqual(['SqlRepository']);
|
||||
});
|
||||
|
||||
|
|
@ -396,7 +399,7 @@ describe('Go structural interface dispatch', () => {
|
|||
);
|
||||
const dispatchTargets = saveCalls
|
||||
.filter((edge) => edge.rel.reason === 'interface-dispatch')
|
||||
.map((edge) => owningTypeName(edge.rel.targetId))
|
||||
.map((edge) => owningTypeName(result, edge.rel.targetId))
|
||||
.sort();
|
||||
expect(dispatchTargets).toEqual(['MemoryRepository', 'SqlRepository']);
|
||||
});
|
||||
|
|
@ -453,7 +456,7 @@ describe('Go structural interface dispatch', () => {
|
|||
);
|
||||
const dispatchTargets = closeCalls
|
||||
.filter((edge) => edge.rel.reason === 'interface-dispatch')
|
||||
.map((edge) => owningTypeName(edge.rel.targetId))
|
||||
.map((edge) => owningTypeName(result, edge.rel.targetId))
|
||||
.sort();
|
||||
expect(dispatchTargets).toEqual(['File']);
|
||||
});
|
||||
|
|
@ -469,15 +472,6 @@ describe('Go cross-package structural interface dispatch', () => {
|
|||
);
|
||||
}, 60000);
|
||||
|
||||
function owningTypeName(methodId: string): string {
|
||||
for (const rel of result.graph.iterRelationshipsByType('HAS_METHOD')) {
|
||||
if (rel.targetId !== methodId) continue;
|
||||
const owner = result.graph.getNode(rel.sourceId);
|
||||
return (owner?.properties.name ?? rel.sourceId) as string;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
it('matches local interface types against package-qualified implementation signatures', () => {
|
||||
const implementsEdges = getRelationships(result, 'IMPLEMENTS').filter((edge) =>
|
||||
(edge.rel.reason ?? '').startsWith('go-structural-implements'),
|
||||
|
|
@ -501,7 +495,7 @@ describe('Go cross-package structural interface dispatch', () => {
|
|||
);
|
||||
const dispatchTargets = saveCalls
|
||||
.filter((edge) => edge.rel.reason === 'interface-dispatch')
|
||||
.map((edge) => owningTypeName(edge.rel.targetId))
|
||||
.map((edge) => owningTypeName(result, edge.rel.targetId))
|
||||
.sort();
|
||||
expect(dispatchTargets).toEqual(['GoodStore']);
|
||||
});
|
||||
|
|
@ -512,7 +506,7 @@ describe('Go cross-package structural interface dispatch', () => {
|
|||
);
|
||||
const dispatchTargets = closeCalls
|
||||
.filter((edge) => edge.rel.reason === 'interface-dispatch')
|
||||
.map((edge) => owningTypeName(edge.rel.targetId))
|
||||
.map((edge) => owningTypeName(result, edge.rel.targetId))
|
||||
.sort();
|
||||
expect(dispatchTargets).toEqual(['File']);
|
||||
});
|
||||
|
|
@ -2156,3 +2150,76 @@ describe('Go grouped type declaration scoping (#2837)', () => {
|
|||
expect(methods).not.toContain('AuditSink.Observe');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Out-of-repo package qualifiers in signatures (#2873)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Go signatures naming out-of-repo packages', () => {
|
||||
let result: PipelineResult;
|
||||
|
||||
beforeAll(async () => {
|
||||
result = await runPipelineFromRepo(
|
||||
path.join(FIXTURES, 'go-extern-qualified-signatures'),
|
||||
() => {},
|
||||
);
|
||||
}, 60000);
|
||||
|
||||
// `context.Context` resolves to no file in the repo, which used to collapse the
|
||||
// whole signature to `undefined` on BOTH sides — so identical signatures
|
||||
// compared unequal and no Go interface with a `ctx` parameter was ever
|
||||
// implemented.
|
||||
it('emits structural IMPLEMENTS across packages for stdlib-qualified signatures', () => {
|
||||
const implementsEdges = getRelationships(result, 'IMPLEMENTS').filter((edge) =>
|
||||
(edge.rel.reason ?? '').startsWith('go-structural-implements'),
|
||||
);
|
||||
expect(edgeSet(implementsEdges)).toEqual(['Mem → Store']);
|
||||
});
|
||||
|
||||
// Two different out-of-repo packages sharing a last path segment must stay
|
||||
// distinct: the qualifier is keyed on the import PATH, not the local name.
|
||||
it('does not match same-named out-of-repo packages from different import paths', () => {
|
||||
// Exact-set, not `not.toContain`: an empty edge list would satisfy the
|
||||
// negative on its own, and the positive above is what proves it non-empty.
|
||||
const implementsEdges = getRelationships(result, 'IMPLEMENTS');
|
||||
expect(edgeSet(implementsEdges)).toEqual(['Mem → Store']);
|
||||
});
|
||||
|
||||
it('dispatches an interface-typed field call to the implementor', () => {
|
||||
const dispatched = getRelationships(result, 'CALLS')
|
||||
.filter((edge) => edge.source === 'Remove' && edge.rel.reason === 'interface-dispatch')
|
||||
.map((edge) => `${owningTypeName(result, edge.rel.targetId)}.${edge.target}`);
|
||||
expect(dispatched).toEqual(['Mem.Delete']);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Undecided satisfaction reaches the pipeline result (#2873)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Go undecided interface satisfaction', () => {
|
||||
let result: PipelineResult;
|
||||
|
||||
beforeAll(async () => {
|
||||
result = await runPipelineFromRepo(path.join(FIXTURES, 'go-undecided-satisfaction'), () => {});
|
||||
}, 60000);
|
||||
|
||||
// The pair is unjudged, so it mints no edge — and saying so is the whole
|
||||
// point: an empty implementor list here is a question, not an answer.
|
||||
it('reports the interface it could not decide, and only that one', () => {
|
||||
const undecided = result.undecidedSatisfaction.map((entry) => entry.interfaceName).sort();
|
||||
expect(undecided).toEqual(['Drawer']);
|
||||
});
|
||||
|
||||
it('names the candidate type, so a query on the implementation can be hedged', () => {
|
||||
const drawer = result.undecidedSatisfaction.find((e) => e.interfaceName === 'Drawer');
|
||||
expect(drawer?.candidateNames).toEqual(['Canvas']);
|
||||
});
|
||||
|
||||
it('still emits the IMPLEMENTS edge it could decide', () => {
|
||||
const implementsEdges = getRelationships(result, 'IMPLEMENTS').filter((edge) =>
|
||||
(edge.rel.reason ?? '').startsWith('go-structural-implements'),
|
||||
);
|
||||
expect(edgeSet(implementsEdges)).toEqual(['Label → Named']);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import type {
|
|||
BindingRef,
|
||||
Callsite,
|
||||
ImportEdge,
|
||||
ParsedImport,
|
||||
ReferenceSite,
|
||||
Scope,
|
||||
ScopeId,
|
||||
|
|
@ -172,18 +173,20 @@ function parsedGoDefs(
|
|||
options: {
|
||||
readonly scopes?: readonly Scope[];
|
||||
readonly referenceSites?: readonly ReferenceSite[];
|
||||
readonly parsedImports?: readonly ParsedImport[];
|
||||
} = {},
|
||||
) {
|
||||
return [
|
||||
{
|
||||
filePath: 'repo.go',
|
||||
language: 'go',
|
||||
scopes: options.scopes ?? [],
|
||||
imports: [],
|
||||
localDefs: [...defs],
|
||||
referenceSites: options.referenceSites ?? [],
|
||||
},
|
||||
] as any;
|
||||
return [parsedGoFile('repo.go', defs, options)] as any;
|
||||
}
|
||||
|
||||
/** `import <local> "<path>"` as the extractor records it (#2873).
|
||||
*
|
||||
* Both names carry the alias when there is one — `import-decomposer.ts` puts
|
||||
* the alias in `@import.name` — so an aliased import is modelled by passing a
|
||||
* `localName` that differs from the path's last segment, NOT by the two name
|
||||
* fields disagreeing. They never disagree on real Go input. */
|
||||
function goNamespaceImport(localName: string, targetRaw: string): ParsedImport {
|
||||
return { kind: 'namespace', localName, importedName: localName, targetRaw };
|
||||
}
|
||||
|
||||
function parsedGoFile(
|
||||
|
|
@ -192,6 +195,8 @@ function parsedGoFile(
|
|||
options: {
|
||||
readonly scopes?: readonly Scope[];
|
||||
readonly referenceSites?: readonly ReferenceSite[];
|
||||
readonly parsedImports?: readonly ParsedImport[];
|
||||
readonly moduleScope?: ScopeId;
|
||||
} = {},
|
||||
) {
|
||||
return {
|
||||
|
|
@ -199,6 +204,8 @@ function parsedGoFile(
|
|||
language: 'go',
|
||||
scopes: options.scopes ?? [],
|
||||
imports: [],
|
||||
parsedImports: options.parsedImports ?? [],
|
||||
moduleScope: options.moduleScope,
|
||||
localDefs: [...defs],
|
||||
referenceSites: options.referenceSites ?? [],
|
||||
} as any;
|
||||
|
|
@ -281,10 +288,10 @@ function inheritsSite(name: string, inScope: ScopeId): ReferenceSite {
|
|||
* it explicitly.
|
||||
*/
|
||||
function implIds(
|
||||
result: Map<string, readonly { readonly structDefId: string }[]>,
|
||||
result: { readonly implementations: Map<string, readonly { readonly structDefId: string }[]> },
|
||||
ifaceId: string,
|
||||
): string[] | undefined {
|
||||
const found = result.get(ifaceId);
|
||||
const found = result.implementations.get(ifaceId);
|
||||
// Deliberately NOT sorted: several rows below assert detection ORDER
|
||||
// (shallowest-promoted-first), which sorting would silently destroy.
|
||||
return found === undefined ? undefined : found.map((i) => i.structDefId);
|
||||
|
|
@ -374,7 +381,7 @@ describe('Go structural interface detection', () => {
|
|||
);
|
||||
|
||||
expect(implIds(result, iface.nodeId)).toEqual([struct.nodeId]);
|
||||
expect(result.get(iface.nodeId)?.[0]?.receiverForm).toBe('pointer');
|
||||
expect(result.implementations.get(iface.nodeId)?.[0]?.receiverForm).toBe('pointer');
|
||||
});
|
||||
|
||||
it('rejects same-name methods with incompatible parameter types', () => {
|
||||
|
|
@ -405,7 +412,7 @@ describe('Go structural interface detection', () => {
|
|||
{} as any,
|
||||
);
|
||||
|
||||
expect(result.get(iface.nodeId)).toBeUndefined();
|
||||
expect(implIds(result, iface.nodeId)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('preserves Go parameter type shape when checking signatures', () => {
|
||||
|
|
@ -436,7 +443,7 @@ describe('Go structural interface detection', () => {
|
|||
{} as any,
|
||||
);
|
||||
|
||||
expect(result.get(iface.nodeId)).toBeUndefined();
|
||||
expect(implIds(result, iface.nodeId)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not conflate variadic and slice parameter types in interface signatures', () => {
|
||||
|
|
@ -467,7 +474,7 @@ describe('Go structural interface detection', () => {
|
|||
{} as any,
|
||||
);
|
||||
|
||||
expect(result.get(iface.nodeId)).toBeUndefined();
|
||||
expect(implIds(result, iface.nodeId)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('preserves variadic element package identity when checking signatures', () => {
|
||||
|
|
@ -508,7 +515,7 @@ describe('Go structural interface detection', () => {
|
|||
{} as any,
|
||||
);
|
||||
|
||||
expect(result.get(iface.nodeId)).toBeUndefined();
|
||||
expect(implIds(result, iface.nodeId)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('requires methods inherited from embedded interfaces', () => {
|
||||
|
|
@ -555,7 +562,7 @@ describe('Go structural interface detection', () => {
|
|||
{} as any,
|
||||
);
|
||||
|
||||
expect(result.get(readCloser.nodeId)).toBeUndefined();
|
||||
expect(implIds(result, readCloser.nodeId)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('accepts structs implementing methods from embedded interfaces', () => {
|
||||
|
|
@ -900,8 +907,8 @@ describe('Go structural interface detection', () => {
|
|||
{} as any,
|
||||
);
|
||||
|
||||
expect(result.get(ifaceA.nodeId)).toBeUndefined();
|
||||
expect(result.get(ifaceB.nodeId)).toBeUndefined();
|
||||
expect(implIds(result, ifaceA.nodeId)).toBeUndefined();
|
||||
expect(implIds(result, ifaceB.nodeId)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('allows one struct to satisfy multiple unrelated interfaces', () => {
|
||||
|
|
@ -974,7 +981,7 @@ describe('Go structural interface detection', () => {
|
|||
{} as any,
|
||||
);
|
||||
|
||||
expect(result.get(readCloser.nodeId)).toBeUndefined();
|
||||
expect(implIds(result, readCloser.nodeId)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('allows embedded empty interfaces to contribute no required methods', () => {
|
||||
|
|
@ -1029,7 +1036,7 @@ describe('Go structural interface detection', () => {
|
|||
{} as any,
|
||||
);
|
||||
|
||||
expect(result.get(iface.nodeId)).toBeUndefined();
|
||||
expect(implIds(result, iface.nodeId)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not match signatures with unresolved import-qualified types', () => {
|
||||
|
|
@ -1054,7 +1061,394 @@ describe('Go structural interface detection', () => {
|
|||
{} as any,
|
||||
);
|
||||
|
||||
expect(result.get(iface.nodeId)).toBeUndefined();
|
||||
expect(implIds(result, iface.nodeId)).toBeUndefined();
|
||||
// …but it is NOT reported as a decided negative. Nothing about `missing.User`
|
||||
// was ever compared, and a consumer that reads the empty implementor list as
|
||||
// "nobody implements Saver" is reading a question as an answer (#2873).
|
||||
expect(result.undecided).toEqual([
|
||||
{
|
||||
interfaceDefId: iface.nodeId,
|
||||
interfaceName: 'Saver',
|
||||
filePath: 'repo.go',
|
||||
undecidedCandidates: 1,
|
||||
// Both sides recorded: a query on `Repo` — or on one of its methods —
|
||||
// has to be hedged too, and it can never reach `Saver` through the
|
||||
// graph because the edge that would take it there is the missing thing.
|
||||
candidateNames: ['Repo'],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('reports a decided mismatch as decided, with nothing undecided', () => {
|
||||
const iface = goDef('iface:Saver', 'Interface', 'Saver');
|
||||
const struct = goDef('struct:Repo', 'Struct', 'Repo');
|
||||
const ifaceSave = goDef('iface:Saver.Save', 'Method', 'Saver.Save', iface.nodeId, {
|
||||
parameterCount: 1,
|
||||
requiredParameterCount: 1,
|
||||
parameterTypes: ['string'],
|
||||
returnType: 'error',
|
||||
});
|
||||
const structSave = goDef('struct:Repo.Save', 'Method', 'Repo.Save', struct.nodeId, {
|
||||
parameterCount: 1,
|
||||
requiredParameterCount: 1,
|
||||
parameterTypes: ['int'],
|
||||
returnType: 'error',
|
||||
});
|
||||
|
||||
const result = detectGoInterfaceImplementations(
|
||||
parsedGoDefs([iface, struct, ifaceSave, structSave]),
|
||||
emptyIndexes,
|
||||
{} as any,
|
||||
);
|
||||
|
||||
expect(implIds(result, iface.nodeId)).toBeUndefined();
|
||||
expect(result.undecided).toEqual([]);
|
||||
});
|
||||
|
||||
// A hard no anywhere in the method set beats an unknown elsewhere: the type
|
||||
// provably lacks a required method, so the answer is trustworthy.
|
||||
it('prefers a decided mismatch over an unknown in the same method set', () => {
|
||||
const iface = goDef('iface:Saver', 'Interface', 'Saver');
|
||||
const struct = goDef('struct:Repo', 'Struct', 'Repo');
|
||||
const ifaceSave = goDef('iface:Saver.Save', 'Method', 'Saver.Save', iface.nodeId, {
|
||||
parameterCount: 1,
|
||||
requiredParameterCount: 1,
|
||||
parameterTypes: ['missing.User'],
|
||||
returnType: 'error',
|
||||
});
|
||||
const ifaceLoad = goDef('iface:Saver.Load', 'Method', 'Saver.Load', iface.nodeId, {
|
||||
parameterCount: 1,
|
||||
requiredParameterCount: 1,
|
||||
parameterTypes: ['string'],
|
||||
returnType: 'error',
|
||||
});
|
||||
const structSave = goDef('struct:Repo.Save', 'Method', 'Repo.Save', struct.nodeId, {
|
||||
parameterCount: 1,
|
||||
requiredParameterCount: 1,
|
||||
parameterTypes: ['missing.User'],
|
||||
returnType: 'error',
|
||||
});
|
||||
const structLoad = goDef('struct:Repo.Load', 'Method', 'Repo.Load', struct.nodeId, {
|
||||
parameterCount: 1,
|
||||
requiredParameterCount: 1,
|
||||
parameterTypes: ['int'],
|
||||
returnType: 'error',
|
||||
});
|
||||
|
||||
const result = detectGoInterfaceImplementations(
|
||||
parsedGoDefs([iface, struct, ifaceSave, ifaceLoad, structSave, structLoad]),
|
||||
emptyIndexes,
|
||||
{} as any,
|
||||
);
|
||||
|
||||
expect(implIds(result, iface.nodeId)).toBeUndefined();
|
||||
expect(result.undecided).toEqual([]);
|
||||
});
|
||||
|
||||
// #2873: an out-of-repo package resolves to no file, so it used to be absent
|
||||
// from the qualifier map, and a missing qualifier collapsed the whole signature
|
||||
// to `undefined` — which both compatibility checks read as "differs". Since
|
||||
// `ctx context.Context` opens nearly every idiomatic Go method, that left
|
||||
// structural satisfaction working only for builtin-only signatures.
|
||||
it('matches signatures qualified by the same out-of-repo package', () => {
|
||||
const iface = goDef('iface:Saver', 'Interface', 'Saver');
|
||||
const struct = goDef('struct:Repo', 'Struct', 'Repo');
|
||||
const ifaceSave = goDef('iface:Saver.Save', 'Method', 'Saver.Save', iface.nodeId, {
|
||||
parameterCount: 2,
|
||||
requiredParameterCount: 2,
|
||||
parameterTypes: ['context.Context', 'string'],
|
||||
returnType: 'error',
|
||||
});
|
||||
const structSave = goDef('struct:Repo.Save', 'Method', 'Repo.Save', struct.nodeId, {
|
||||
parameterCount: 2,
|
||||
requiredParameterCount: 2,
|
||||
parameterTypes: ['context.Context', 'string'],
|
||||
returnType: 'error',
|
||||
});
|
||||
|
||||
const result = detectGoInterfaceImplementations(
|
||||
parsedGoDefs([iface, struct, ifaceSave, structSave], {
|
||||
parsedImports: [goNamespaceImport('context', 'context')],
|
||||
}),
|
||||
emptyIndexes,
|
||||
{} as any,
|
||||
);
|
||||
|
||||
expect(implIds(result, iface.nodeId)).toEqual([struct.nodeId]);
|
||||
});
|
||||
|
||||
it('matches an out-of-repo return type across packages', () => {
|
||||
const iface = goDef('iface:Ctxer', 'Interface', 'Ctxer', undefined, {
|
||||
filePath: 'api/ctx.go',
|
||||
});
|
||||
const struct = goDef('struct:Impl', 'Struct', 'Impl', undefined, { filePath: 'store/ctx.go' });
|
||||
const ifaceCtx = goDef('iface:Ctxer.Ctx', 'Method', 'Ctxer.Ctx', iface.nodeId, {
|
||||
filePath: 'api/ctx.go',
|
||||
parameterCount: 0,
|
||||
requiredParameterCount: 0,
|
||||
returnType: 'context.Context',
|
||||
});
|
||||
const implCtx = goDef('struct:Impl.Ctx', 'Method', 'Impl.Ctx', struct.nodeId, {
|
||||
filePath: 'store/ctx.go',
|
||||
parameterCount: 0,
|
||||
requiredParameterCount: 0,
|
||||
returnType: 'context.Context',
|
||||
});
|
||||
const defs = [iface, struct, ifaceCtx, implCtx];
|
||||
|
||||
const result = detectGoInterfaceImplementations(
|
||||
[
|
||||
parsedGoFile('api/ctx.go', [iface, ifaceCtx], {
|
||||
parsedImports: [goNamespaceImport('context', 'context')],
|
||||
}),
|
||||
parsedGoFile('store/ctx.go', [struct, implCtx], {
|
||||
parsedImports: [goNamespaceImport('context', 'context')],
|
||||
}),
|
||||
],
|
||||
scopeIndexes(defs),
|
||||
{} as any,
|
||||
);
|
||||
|
||||
expect(implIds(result, iface.nodeId)).toEqual([struct.nodeId]);
|
||||
});
|
||||
|
||||
// The import PATH is the identity, not the local name: an alias changes only
|
||||
// how one file spells the package.
|
||||
it('matches an aliased out-of-repo import against its unaliased spelling', () => {
|
||||
const iface = goDef('iface:Saver', 'Interface', 'Saver', undefined, { filePath: 'api/s.go' });
|
||||
const struct = goDef('struct:Repo', 'Struct', 'Repo', undefined, { filePath: 'store/s.go' });
|
||||
const ifaceSave = goDef('iface:Saver.Save', 'Method', 'Saver.Save', iface.nodeId, {
|
||||
filePath: 'api/s.go',
|
||||
parameterCount: 1,
|
||||
requiredParameterCount: 1,
|
||||
parameterTypes: ['c.Context'],
|
||||
returnType: 'error',
|
||||
});
|
||||
const structSave = goDef('struct:Repo.Save', 'Method', 'Repo.Save', struct.nodeId, {
|
||||
filePath: 'store/s.go',
|
||||
parameterCount: 1,
|
||||
requiredParameterCount: 1,
|
||||
parameterTypes: ['context.Context'],
|
||||
returnType: 'error',
|
||||
});
|
||||
const defs = [iface, struct, ifaceSave, structSave];
|
||||
|
||||
const result = detectGoInterfaceImplementations(
|
||||
[
|
||||
parsedGoFile('api/s.go', [iface, ifaceSave], {
|
||||
parsedImports: [goNamespaceImport('c', 'context')],
|
||||
}),
|
||||
parsedGoFile('store/s.go', [struct, structSave], {
|
||||
parsedImports: [goNamespaceImport('context', 'context')],
|
||||
}),
|
||||
],
|
||||
scopeIndexes(defs),
|
||||
{} as any,
|
||||
);
|
||||
|
||||
expect(implIds(result, iface.nodeId)).toEqual([struct.nodeId]);
|
||||
});
|
||||
|
||||
// The other half of keying on the path: two DIFFERENT out-of-repo packages
|
||||
// whose last segment collides still have to compare unequal.
|
||||
it('rejects same-named out-of-repo packages with different import paths', () => {
|
||||
const iface = goDef('iface:Dialer', 'Interface', 'Dialer', undefined, { filePath: 'api/d.go' });
|
||||
const struct = goDef('struct:Impl', 'Struct', 'Impl', undefined, { filePath: 'store/d.go' });
|
||||
const ifaceDial = goDef('iface:Dialer.Dial', 'Method', 'Dialer.Dial', iface.nodeId, {
|
||||
filePath: 'api/d.go',
|
||||
parameterCount: 1,
|
||||
requiredParameterCount: 1,
|
||||
parameterTypes: ['client.Config'],
|
||||
returnType: 'error',
|
||||
});
|
||||
const implDial = goDef('struct:Impl.Dial', 'Method', 'Impl.Dial', struct.nodeId, {
|
||||
filePath: 'store/d.go',
|
||||
parameterCount: 1,
|
||||
requiredParameterCount: 1,
|
||||
parameterTypes: ['client.Config'],
|
||||
returnType: 'error',
|
||||
});
|
||||
const defs = [iface, struct, ifaceDial, implDial];
|
||||
|
||||
const result = detectGoInterfaceImplementations(
|
||||
[
|
||||
parsedGoFile('api/d.go', [iface, ifaceDial], {
|
||||
parsedImports: [goNamespaceImport('client', 'example.com/alpha/client')],
|
||||
}),
|
||||
parsedGoFile('store/d.go', [struct, implDial], {
|
||||
parsedImports: [goNamespaceImport('client', 'example.com/beta/client')],
|
||||
}),
|
||||
],
|
||||
scopeIndexes(defs),
|
||||
{} as any,
|
||||
);
|
||||
|
||||
expect(implIds(result, iface.nodeId)).toBeUndefined();
|
||||
});
|
||||
|
||||
// The fallback fills gaps, it does not compete: an import that DID resolve
|
||||
// in-repo keeps its package directory, which is the spelling a file inside
|
||||
// that package produces for its own bare type names.
|
||||
it('prefers the in-repo package directory over the import path', () => {
|
||||
const iface = goDef('iface:Saver', 'Interface', 'Saver', undefined, { filePath: 'api/s.go' });
|
||||
const struct = goDef('struct:User', 'Struct', 'User', undefined, {
|
||||
filePath: 'model/user.go',
|
||||
});
|
||||
const ifaceSave = goDef('iface:Saver.Save', 'Method', 'Saver.Save', iface.nodeId, {
|
||||
filePath: 'api/s.go',
|
||||
parameterCount: 1,
|
||||
requiredParameterCount: 1,
|
||||
parameterTypes: ['model.User'],
|
||||
returnType: 'error',
|
||||
});
|
||||
// Declared inside package `model`, so it spells its own type bare.
|
||||
const structSave = goDef('struct:User.Save', 'Method', 'User.Save', struct.nodeId, {
|
||||
filePath: 'model/user.go',
|
||||
parameterCount: 1,
|
||||
requiredParameterCount: 1,
|
||||
parameterTypes: ['User'],
|
||||
returnType: 'error',
|
||||
});
|
||||
const defs = [iface, struct, ifaceSave, structSave];
|
||||
const apiScope = 'scope:api' as ScopeId;
|
||||
|
||||
const result = detectGoInterfaceImplementations(
|
||||
[
|
||||
parsedGoFile('api/s.go', [iface, ifaceSave], {
|
||||
moduleScope: apiScope,
|
||||
// Both channels name `model`; only the resolved edge may win.
|
||||
parsedImports: [goNamespaceImport('model', 'example.com/x/model')],
|
||||
}),
|
||||
parsedGoFile('model/user.go', [struct, structSave]),
|
||||
],
|
||||
scopeIndexes(defs, [], {
|
||||
imports: new Map([
|
||||
[
|
||||
apiScope,
|
||||
[
|
||||
{
|
||||
kind: 'namespace',
|
||||
localName: 'model',
|
||||
targetFile: 'model/user.go',
|
||||
targetExportedName: 'model',
|
||||
} as ImportEdge,
|
||||
],
|
||||
],
|
||||
]),
|
||||
}),
|
||||
{} as any,
|
||||
);
|
||||
|
||||
expect(implIds(result, iface.nodeId)).toEqual([struct.nodeId]);
|
||||
});
|
||||
|
||||
// The extractor derives the local name from the LAST path segment, which for a
|
||||
// module at v2+ is the version, not the package. Source still writes `bar.`.
|
||||
it('recovers the package name from a major-version import path', () => {
|
||||
const iface = goDef('iface:Saver', 'Interface', 'Saver', undefined, { filePath: 'api/s.go' });
|
||||
const struct = goDef('struct:Repo', 'Struct', 'Repo', undefined, { filePath: 'store/s.go' });
|
||||
const ifaceSave = goDef('iface:Saver.Save', 'Method', 'Saver.Save', iface.nodeId, {
|
||||
filePath: 'api/s.go',
|
||||
parameterCount: 2,
|
||||
requiredParameterCount: 2,
|
||||
parameterTypes: ['bar.Config', 'yaml.Node'],
|
||||
returnType: 'error',
|
||||
});
|
||||
const structSave = goDef('struct:Repo.Save', 'Method', 'Repo.Save', struct.nodeId, {
|
||||
filePath: 'store/s.go',
|
||||
parameterCount: 2,
|
||||
requiredParameterCount: 2,
|
||||
parameterTypes: ['bar.Config', 'yaml.Node'],
|
||||
returnType: 'error',
|
||||
});
|
||||
const defs = [iface, struct, ifaceSave, structSave];
|
||||
const imports = [
|
||||
goNamespaceImport('v2', 'github.com/foo/bar/v2'),
|
||||
goNamespaceImport('yaml.v3', 'gopkg.in/yaml.v3'),
|
||||
];
|
||||
|
||||
const result = detectGoInterfaceImplementations(
|
||||
[
|
||||
parsedGoFile('api/s.go', [iface, ifaceSave], { parsedImports: imports }),
|
||||
parsedGoFile('store/s.go', [struct, structSave], { parsedImports: imports }),
|
||||
],
|
||||
scopeIndexes(defs),
|
||||
{} as any,
|
||||
);
|
||||
|
||||
expect(implIds(result, iface.nodeId)).toEqual([struct.nodeId]);
|
||||
});
|
||||
|
||||
// Two majors of one module are two packages, and Go forces an alias to import
|
||||
// both. The alias owns its token; the bare name stays with the unaliased one.
|
||||
it('keeps two major versions of the same module distinct', () => {
|
||||
const iface = goDef('iface:Saver', 'Interface', 'Saver', undefined, { filePath: 'api/s.go' });
|
||||
const struct = goDef('struct:Repo', 'Struct', 'Repo', undefined, { filePath: 'store/s.go' });
|
||||
const ifaceSave = goDef('iface:Saver.Save', 'Method', 'Saver.Save', iface.nodeId, {
|
||||
filePath: 'api/s.go',
|
||||
parameterCount: 1,
|
||||
requiredParameterCount: 1,
|
||||
parameterTypes: ['bar.Config'],
|
||||
returnType: 'error',
|
||||
});
|
||||
const structSave = goDef('struct:Repo.Save', 'Method', 'Repo.Save', struct.nodeId, {
|
||||
filePath: 'store/s.go',
|
||||
parameterCount: 1,
|
||||
requiredParameterCount: 1,
|
||||
parameterTypes: ['bar.Config'],
|
||||
returnType: 'error',
|
||||
});
|
||||
const defs = [iface, struct, ifaceSave, structSave];
|
||||
|
||||
const result = detectGoInterfaceImplementations(
|
||||
[
|
||||
// `bar` here is v1 …
|
||||
parsedGoFile('api/s.go', [iface, ifaceSave], {
|
||||
parsedImports: [goNamespaceImport('bar', 'github.com/foo/bar')],
|
||||
}),
|
||||
// … and here it is the alias of v2, imported alongside v1.
|
||||
parsedGoFile('store/s.go', [struct, structSave], {
|
||||
parsedImports: [
|
||||
goNamespaceImport('v1', 'github.com/foo/bar'),
|
||||
goNamespaceImport('bar', 'github.com/foo/bar/v2'),
|
||||
],
|
||||
}),
|
||||
],
|
||||
scopeIndexes(defs),
|
||||
{} as any,
|
||||
);
|
||||
|
||||
expect(implIds(result, iface.nodeId)).toBeUndefined();
|
||||
});
|
||||
|
||||
// Go's dot-import has no qualifier to register, and the extractor gives it a
|
||||
// different `ParsedImport` kind for that reason. Blank imports never reach
|
||||
// here at all.
|
||||
it('registers no qualifier for a dot-import', () => {
|
||||
const iface = goDef('iface:Saver', 'Interface', 'Saver');
|
||||
const struct = goDef('struct:Repo', 'Struct', 'Repo');
|
||||
const ifaceSave = goDef('iface:Saver.Save', 'Method', 'Saver.Save', iface.nodeId, {
|
||||
parameterCount: 1,
|
||||
requiredParameterCount: 1,
|
||||
parameterTypes: ['dotted.User'],
|
||||
returnType: 'error',
|
||||
});
|
||||
const structSave = goDef('struct:Repo.Save', 'Method', 'Repo.Save', struct.nodeId, {
|
||||
parameterCount: 1,
|
||||
requiredParameterCount: 1,
|
||||
parameterTypes: ['dotted.User'],
|
||||
returnType: 'error',
|
||||
});
|
||||
|
||||
const result = detectGoInterfaceImplementations(
|
||||
parsedGoDefs([iface, struct, ifaceSave, structSave], {
|
||||
parsedImports: [{ kind: 'wildcard', targetRaw: 'example.com/x/dotted' } as ParsedImport],
|
||||
}),
|
||||
emptyIndexes,
|
||||
{} as any,
|
||||
);
|
||||
|
||||
expect(implIds(result, iface.nodeId)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects methods missing an interface-required return type', () => {
|
||||
|
|
@ -1082,7 +1476,7 @@ describe('Go structural interface detection', () => {
|
|||
{} as any,
|
||||
);
|
||||
|
||||
expect(result.get(iface.nodeId)).toBeUndefined();
|
||||
expect(implIds(result, iface.nodeId)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects methods with fewer grouped return values than the interface requires', () => {
|
||||
|
|
@ -1111,7 +1505,7 @@ describe('Go structural interface detection', () => {
|
|||
{} as any,
|
||||
);
|
||||
|
||||
expect(result.get(iface.nodeId)).toBeUndefined();
|
||||
expect(implIds(result, iface.nodeId)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects interface methods without enough signature metadata', () => {
|
||||
|
|
@ -1137,7 +1531,7 @@ describe('Go structural interface detection', () => {
|
|||
{} as any,
|
||||
);
|
||||
|
||||
expect(result.get(iface.nodeId)).toBeUndefined();
|
||||
expect(implIds(result, iface.nodeId)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,71 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
MAX_UNDECIDED_INTERFACES,
|
||||
summarizeUndecidedSatisfaction,
|
||||
} from '../../../src/core/ingestion/scope-resolution/undecided-satisfaction.js';
|
||||
import type { UndecidedSatisfaction } from '../../../src/core/ingestion/scope-resolution/contract/scope-resolver.js';
|
||||
|
||||
function record(interfaceName: string, candidateNames: readonly string[]): UndecidedSatisfaction {
|
||||
return {
|
||||
interfaceDefId: `iface:${interfaceName}`,
|
||||
interfaceName,
|
||||
filePath: 'store/store.go',
|
||||
undecidedCandidates: candidateNames.length,
|
||||
candidateNames,
|
||||
};
|
||||
}
|
||||
|
||||
describe('summarizeUndecidedSatisfaction', () => {
|
||||
// Absence has to stay distinguishable from a zeroed record: an index that
|
||||
// decided everything and an index written before this field existed both read
|
||||
// as absent, and neither is "we looked and found nothing to report".
|
||||
it('returns undefined when nothing was undecided', () => {
|
||||
expect(summarizeUndecidedSatisfaction([])).toBeUndefined();
|
||||
});
|
||||
|
||||
it('records both sides of every undecided pair', () => {
|
||||
const summary = summarizeUndecidedSatisfaction([
|
||||
record('CtxStore', ['CtxStoreImpl', 'MemStore']),
|
||||
record('RetStore', ['CtxStoreImpl']),
|
||||
]);
|
||||
|
||||
expect(summary).toEqual({
|
||||
counts: { CtxStore: 2, RetStore: 1 },
|
||||
totalInterfaces: 2,
|
||||
totalCandidates: 3,
|
||||
// `CtxStoreImpl` was a candidate for BOTH interfaces — this is the key a
|
||||
// query on the implementation matches against, and the reason the
|
||||
// reported symptom (`impact` on the impl method) can be hedged at all.
|
||||
candidateCounts: { CtxStoreImpl: 2, MemStore: 1 },
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the true totals when the map is capped', () => {
|
||||
const many = Array.from({ length: MAX_UNDECIDED_INTERFACES + 10 }, (_, i) =>
|
||||
record(`Iface${String(i).padStart(4, '0')}`, [`Impl${i}`]),
|
||||
);
|
||||
|
||||
const summary = summarizeUndecidedSatisfaction(many)!;
|
||||
|
||||
expect(Object.keys(summary.counts)).toHaveLength(MAX_UNDECIDED_INTERFACES);
|
||||
// The sample is visibly a sample: totals count everything, including what
|
||||
// the cap dropped, so a consumer can never mistake `counts` for the whole.
|
||||
expect(summary.totalInterfaces).toBe(MAX_UNDECIDED_INTERFACES + 10);
|
||||
expect(summary.totalCandidates).toBe(MAX_UNDECIDED_INTERFACES + 10);
|
||||
// No `omittedInterfaces`: it is exactly `totalInterfaces - keys(counts)`,
|
||||
// and one persisted field per fact is enough.
|
||||
expect(summary.omittedCandidates).toBe(10);
|
||||
});
|
||||
|
||||
// The tiebreak decides WHICH entries survive the cap, so it has to be stable
|
||||
// across machines — a locale-sensitive compare would not be.
|
||||
it('ranks by count, then by name, deterministically', () => {
|
||||
const summary = summarizeUndecidedSatisfaction([
|
||||
record('Zebra', ['A']),
|
||||
record('Alpha', ['A']),
|
||||
record('Busy', ['A', 'B', 'C']),
|
||||
])!;
|
||||
|
||||
expect(Object.keys(summary.counts)).toEqual(['Busy', 'Alpha', 'Zebra']);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue