fix(python): guard decorated subtype targets

Fixes #3398
This commit is contained in:
Gergő Magyar 2026-09-28 18:01:22 +01:00 • committed by GitHub
parent 3d09c85ee3
commit b20c8b6ef2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 94 additions and 4 deletions

View file

@ -121,6 +121,20 @@ export function recordPythonSubtypeMethodShape(
fnNode: SyntaxNode,
mapLine?: LineMapper,
): void {
// An unknown decorator may replace the function or mark it abstract.
// Positional shape alone cannot prove a concrete subtype dispatch target.
// The two built-in descriptor decorators are handled by receiver binding.
const wrapper = fnNode.parent;
if (
wrapper?.type === 'decorated_definition' &&
wrapper.namedChildren.some((child) => {
if (child.type !== 'decorator') return false;
const name = child.firstNamedChild?.text;
return name !== 'staticmethod' && name !== 'classmethod';
})
) {
return;
}
const capacity = positionalCapacity(fnNode);
if (capacity === undefined) return;
const [line, column] = nodePosition(fnNode, mapLine);

View file

@ -800,7 +800,10 @@ import { copyV8CacheIfPresent, tryLoadV8Cache, writeV8CacheFile } from './v8-sid
// v117 (#3390 private-only successor): simple-positional call entries now carry
// their count privately, while ordinary Python references no longer receive
// synthetic arity. Warm v116 ParsedFiles have neither equivalent fact.
const SCHEMA_BUMP = 117;
// v118 (#3398): decorated Python methods with unproven decorator identity no
// longer publish subtype positional capacity. Warm v117 side-channel snapshots
// would retain that capacity and could emit a false concrete call target.
const SCHEMA_BUMP = 118;
const GITNEXUS_PKG_VERSION = (() => {
try {
// package.json sits at gitnexus/package.json — two levels up from

View file

@ -1349,6 +1349,43 @@ describe('Python mixin self-dispatch', () => {
});
});
describe('Python aliased abstract subtype method', () => {
it('does not publish an abstract declaration as a concrete self-dispatch target', async () => {
const repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-python-abstract-alias-'));
try {
writeFixtureRepo(repoDir, {
'worker.py': [
'from abc import ABC, abstractmethod as am',
'class Mixin:',
' def dispatch(self):',
' return self.hook()',
'class AbstractWorker(Mixin, ABC):',
' @am',
' def hook(self):',
' return 1',
].join('\n'),
});
const result = await runPipelineFromRepo(repoDir, () => {});
expect(
getRelationships(result, 'CALLS').filter(
(call) => call.source === 'dispatch' && call.target === 'hook',
),
).toEqual([]);
expect(
getResolutionOutcomes(result).some(
(outcome) =>
outcome.kind === 'suppressed' &&
outcome.filePath === 'worker.py' &&
outcome.name === 'hook' &&
outcome.reason === 'receiver-unresolved',
),
).toBe(true);
} finally {
fs.rmSync(repoDir, { recursive: true, force: true });
}
}, 60000);
});
// ---------------------------------------------------------------------------
// Incomplete Python inheritance must not invent an MRO binding
// ---------------------------------------------------------------------------

View file

@ -297,8 +297,8 @@ describe('PARSE_CACHE_VERSION', () => {
// Moved 114 -> 115 for #3390: statically known Python call arity.
// Moved 115 -> 116 for #3390's Python subtype-dispatch shape side-channel.
// Moved 116 -> 117 for #3390's private positional-count side-channel.
it('pins SCHEMA_BUMP to 117 so concurrent bumps cannot silently collide (#2766, #3015, #3088, #2885, #3128, #2865, #3130, #1432, #3161, #3179, #3219, #3190, #3253, #3273, #3339, #3354, #3371, #2965, #3390)', () => {
expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(117);
it('pins SCHEMA_BUMP to 118 so concurrent bumps cannot silently collide (#2766, #3015, #3088, #2885, #3128, #2865, #3130, #1432, #3161, #3179, #3219, #3190, #3253, #3273, #3339, #3354, #3371, #2965, #3390, #3398)', () => {
expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(118);
expect(PARSE_CACHE_BUCKET_COUNT).toBe(128);
// The PREVIOUS version must fail the reuse gate, not merely differ from the
// current one — a hardcoded number outside the conflict hunk rebases cleanly
@ -307,7 +307,7 @@ describe('PARSE_CACHE_VERSION', () => {
for (const taken of [
59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81,
82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, 101, 102, 103,
104, 105, 106, 107, 108, 109, 110, 111, 112, 113, 114, 115, 116,
104, 105, 106, 107, 108, 109, 110, 111, 112, 113, 114, 115, 116, 117,
]) {
expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).not.toBe(taken);
}

View file

@ -138,4 +138,40 @@ describe('Python missing-member subtype argument shapes', () => {
simplePositionalCalls: [[21, 0, 1]],
});
});
it('does not prove a decorated subtype target when the decorator identity is unknown', () => {
emitPythonScopeCaptures(callerSource, 'caller.py');
emitPythonScopeCaptures(
[
'from abc import abstractmethod as am',
'class AbstractWorker:',
' @am',
' def target(self, value):',
' return value',
'class StaticWorker:',
' @staticmethod',
' def target(value):',
' return value',
'class ClassWorker:',
' @classmethod',
' def target(cls, value):',
' return value',
].join('\n'),
'targets.py',
);
expect(
pythonMissingReceiverSubtypeCandidateCompatibility('caller.py', positionalSite, candidate(4)),
).toBe('unknown');
expect(
pythonMissingReceiverSubtypeCandidateCompatibility('caller.py', positionalSite, candidate(8)),
).toBe('compatible');
expect(
pythonMissingReceiverSubtypeCandidateCompatibility(
'caller.py',
positionalSite,
candidate(12),
),
).toBe('compatible');
});
});