diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts index d203b5304..eeec56d18 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts @@ -2344,6 +2344,7 @@ export function emitReceiverBoundCalls( const ambiguousCandidateIds = new Set(); const unknownCompatibilityCandidateIds = new Set(); const incompleteInheritanceSubtypeIds = new Set(); + const missingMemberSubtypeIds = new Set(); const visitedSubtypeIds = new Set([ownerDef.nodeId]); const subtypeQueue = [ownerDef.nodeId]; let subtypeHead = 0; @@ -2449,7 +2450,15 @@ export function emitReceiverBoundCalls( // indexed MRO, leaving this subtype's target unproven. incompleteInheritanceSubtypeIds.add(subtype.nodeId); } - if (subtypeAmbiguous || picked === undefined) continue; + if (subtypeAmbiguous) continue; + if (picked === undefined) { + // This runtime subtype has no proven binding. Preserve + // partial coverage even when a sibling supplies a target. + if (!incompleteInheritanceSubtypeIds.has(subtype.nodeId)) { + missingMemberSubtypeIds.add(subtype.nodeId); + } + continue; + } subtypeTargets.set(picked.nodeId, picked); } } @@ -2473,6 +2482,7 @@ export function emitReceiverBoundCalls( ...ambiguousCandidateIds, ...unknownCompatibilityCandidateIds, ...incompleteInheritanceSubtypeIds, + ...missingMemberSubtypeIds, ]), MAX_INTERFACE_DISPATCH_FANOUT, ); diff --git a/gitnexus/test/integration/resolvers/python.test.ts b/gitnexus/test/integration/resolvers/python.test.ts index 76d3687e6..2fd1492c6 100644 --- a/gitnexus/test/integration/resolvers/python.test.ts +++ b/gitnexus/test/integration/resolvers/python.test.ts @@ -1354,6 +1354,42 @@ describe('Python mixin self-dispatch', () => { // --------------------------------------------------------------------------- describe('Python incomplete inheritance', () => { + it('records a missing subtype alongside a valid sibling target', async () => { + const repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-python-missing-subtype-')); + try { + writeFixtureRepo(repoDir, { + 'case.py': `class Mixin: + def dispatch(self): + return self.hook() + +class HasHook(Mixin): + def hook(self): + pass + +class MissingHook(Mixin): + pass +`, + }); + const result = await runPipelineFromRepo(repoDir, () => {}); + const calls = getRelationships(result, 'CALLS').filter( + (edge) => edge.source === 'dispatch' && edge.target === 'hook', + ); + expect(calls.map((edge) => edge.rel.targetId)).toEqual([ + expect.stringContaining('HasHook.hook'), + ]); + expect( + getResolutionOutcomes(result).some( + (outcome) => + outcome.name === 'hook' && + outcome.reason === 'receiver-unresolved' && + outcome.candidateIds.some((id) => id.endsWith(':Class:MissingHook')), + ), + ).toBe(true); + } finally { + fs.rmSync(repoDir, { recursive: true, force: true }); + } + }, 60000); + it('ignores a self-named external base without losing the class for its children', async () => { const repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-python-self-parent-')); try {