fix(python): record partial subtype dispatch coverage

Fixes #3395
This commit is contained in:
Gergő Magyar 2026-09-28 17:24:41 +01:00 • committed by GitHub
parent 52581cd4e9
commit 3d09c85ee3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 47 additions and 1 deletions

View file

@ -2344,6 +2344,7 @@ export function emitReceiverBoundCalls(
const ambiguousCandidateIds = new Set<string>();
const unknownCompatibilityCandidateIds = new Set<string>();
const incompleteInheritanceSubtypeIds = new Set<string>();
const missingMemberSubtypeIds = new Set<string>();
const visitedSubtypeIds = new Set<string>([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,
);

View file

@ -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 {