From b20c8b6ef247e331788d84803d5b0e5b2d993a08 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Magyar?= Date: Mon, 28 Sep 2026 18:01:22 +0100 Subject: [PATCH] fix(python): guard decorated subtype targets Fixes #3398 --- .../languages/python/subtype-dispatch.ts | 14 +++++++ gitnexus/src/storage/parse-cache.ts | 5 ++- .../test/integration/resolvers/python.test.ts | 37 +++++++++++++++++++ .../test/unit/incremental-parse-cache.test.ts | 6 +-- .../python/python-subtype-dispatch.test.ts | 36 ++++++++++++++++++ 5 files changed, 94 insertions(+), 4 deletions(-) diff --git a/gitnexus/src/core/ingestion/languages/python/subtype-dispatch.ts b/gitnexus/src/core/ingestion/languages/python/subtype-dispatch.ts index a7e9c9305..08d8fb6d3 100644 --- a/gitnexus/src/core/ingestion/languages/python/subtype-dispatch.ts +++ b/gitnexus/src/core/ingestion/languages/python/subtype-dispatch.ts @@ -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); diff --git a/gitnexus/src/storage/parse-cache.ts b/gitnexus/src/storage/parse-cache.ts index 59dc62d23..232cec0b8 100644 --- a/gitnexus/src/storage/parse-cache.ts +++ b/gitnexus/src/storage/parse-cache.ts @@ -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 diff --git a/gitnexus/test/integration/resolvers/python.test.ts b/gitnexus/test/integration/resolvers/python.test.ts index 2fd1492c6..d147059db 100644 --- a/gitnexus/test/integration/resolvers/python.test.ts +++ b/gitnexus/test/integration/resolvers/python.test.ts @@ -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 // --------------------------------------------------------------------------- diff --git a/gitnexus/test/unit/incremental-parse-cache.test.ts b/gitnexus/test/unit/incremental-parse-cache.test.ts index 550bf510a..91ca4fada 100644 --- a/gitnexus/test/unit/incremental-parse-cache.test.ts +++ b/gitnexus/test/unit/incremental-parse-cache.test.ts @@ -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); } diff --git a/gitnexus/test/unit/scope-resolution/python/python-subtype-dispatch.test.ts b/gitnexus/test/unit/scope-resolution/python/python-subtype-dispatch.test.ts index 97a9f75b7..09ed05e10 100644 --- a/gitnexus/test/unit/scope-resolution/python/python-subtype-dispatch.test.ts +++ b/gitnexus/test/unit/scope-resolution/python/python-subtype-dispatch.test.ts @@ -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'); + }); });