From 0ef3f28d0eb3e82007da1933a95677d36ce6c84b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Magyar?= Date: Mon, 28 Sep 2026 18:34:26 +0100 Subject: [PATCH] fix(python): avoid receiverless subtype targets Fixes #3396 --- .../languages/python/receiver-binding.ts | 11 ++++-- .../languages/python/subtype-dispatch.ts | 5 ++- gitnexus/src/storage/parse-cache.ts | 4 ++- .../test/integration/resolvers/python.test.ts | 35 +++++++++++++++++++ .../test/unit/incremental-parse-cache.test.ts | 7 ++-- .../python/python-subtype-dispatch.test.ts | 27 ++++++++++++++ 6 files changed, 81 insertions(+), 8 deletions(-) diff --git a/gitnexus/src/core/ingestion/languages/python/receiver-binding.ts b/gitnexus/src/core/ingestion/languages/python/receiver-binding.ts index 8bfbbf995..bdf335f17 100644 --- a/gitnexus/src/core/ingestion/languages/python/receiver-binding.ts +++ b/gitnexus/src/core/ingestion/languages/python/receiver-binding.ts @@ -45,6 +45,13 @@ function hasDecorator(fnNode: SyntaxNode, decoratorName: string): boolean { return false; } +/** Static-like descriptors do not inject an instance on attribute access. */ +export function isPythonStaticLikeMethod(fnNode: SyntaxNode): boolean { + return ( + fnNode.childForFieldName('name')?.text === '__new__' || hasDecorator(fnNode, 'staticmethod') + ); +} + function firstBoundReceiverParameter(parameters: SyntaxNode): SyntaxNode | null { for (let i = 0; i < parameters.namedChildCount; i++) { const child = parameters.namedChild(i); @@ -93,14 +100,12 @@ export interface PythonBoundReceiver { */ export function classifyPythonBoundReceiver(fnNode: SyntaxNode): PythonBoundReceiver | null { const enclosingClass = findEnclosingClassDefinition(fnNode); - if (enclosingClass === null || hasDecorator(fnNode, 'staticmethod')) return null; + if (enclosingClass === null || isPythonStaticLikeMethod(fnNode)) return null; const functionName = fnNode.childForFieldName('name')?.text; // Python applies these descriptor kinds implicitly even without decorators. // __new__ is static-like (its class argument is explicit), while // __init_subclass__ and __class_getitem__ receive the class implicitly. - if (functionName === '__new__') return null; - const params = fnNode.childForFieldName('parameters'); if (params === null) return null; const parameter = firstBoundReceiverParameter(params); diff --git a/gitnexus/src/core/ingestion/languages/python/subtype-dispatch.ts b/gitnexus/src/core/ingestion/languages/python/subtype-dispatch.ts index 08d8fb6d3..371031320 100644 --- a/gitnexus/src/core/ingestion/languages/python/subtype-dispatch.ts +++ b/gitnexus/src/core/ingestion/languages/python/subtype-dispatch.ts @@ -1,7 +1,7 @@ import type { ParsedFile, SymbolDefinition } from 'gitnexus-shared'; import type { SyntaxNode } from '../../utils/ast-helpers.js'; import { definitionIdPosition } from '../../scope-resolution/utils/definition-id.js'; -import { classifyPythonBoundReceiver } from './receiver-binding.js'; +import { classifyPythonBoundReceiver, isPythonStaticLikeMethod } from './receiver-binding.js'; type PositionTuple = readonly [line: number, column: number]; type CallShapeTuple = readonly [line: number, column: number, positionalCount: number]; @@ -81,6 +81,9 @@ function positionalCapacity(fnNode: SyntaxNode): number | undefined { const parameters = fnNode.childForFieldName('parameters'); if (parameters === null) return undefined; const receiver = classifyPythonBoundReceiver(fnNode)?.parameter; + // Plain class functions still receive the instance even with no declared + // receiver slot. Without that slot, a zero-argument call is not proven safe. + if (receiver === undefined && !isPythonStaticLikeMethod(fnNode)) return undefined; let capacity = 0; let keywordOnly = false; diff --git a/gitnexus/src/storage/parse-cache.ts b/gitnexus/src/storage/parse-cache.ts index 232cec0b8..c35108aef 100644 --- a/gitnexus/src/storage/parse-cache.ts +++ b/gitnexus/src/storage/parse-cache.ts @@ -803,7 +803,9 @@ import { copyV8CacheIfPresent, tryLoadV8Cache, writeV8CacheFile } from './v8-sid // 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; +// v119 (#3396): Python subtype method capacities now omit receiverless ordinary +// methods. Warm v118 ParsedFiles would replay a false compatible target. +const SCHEMA_BUMP = 119; 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 d147059db..d8b9ff133 100644 --- a/gitnexus/test/integration/resolvers/python.test.ts +++ b/gitnexus/test/integration/resolvers/python.test.ts @@ -1386,6 +1386,41 @@ describe('Python aliased abstract subtype method', () => { }, 60000); }); +describe('Python receiverless subtype method', () => { + it('does not emit a CALLS edge to a method that rejects the injected instance', async () => { + const repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-python-receiverless-subtype-')); + try { + writeFixtureRepo(repoDir, { + 'worker.py': [ + 'class Mixin:', + ' def dispatch(self):', + ' return self.hook()', + 'class Worker(Mixin):', + ' def hook():', + ' pass', + ].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 91ca4fada..a6166ec58 100644 --- a/gitnexus/test/unit/incremental-parse-cache.test.ts +++ b/gitnexus/test/unit/incremental-parse-cache.test.ts @@ -297,8 +297,9 @@ 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 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); + // Moved 117 -> 118 for #3398 and 118 -> 119 for #3396's subtype capacity correction. + it('pins SCHEMA_BUMP to 119 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, #3396)', () => { + expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(119); 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 +308,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, 117, + 104, 105, 106, 107, 108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118, ]) { 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 09ed05e10..fc90521a5 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 @@ -174,4 +174,31 @@ describe('Python missing-member subtype argument shapes', () => { ), ).toBe('compatible'); }); + + it('does not accept a receiverless ordinary method as a zero-argument instance target', () => { + emitPythonScopeCaptures( + 'class Caller:\n def dispatch(self):\n return self.target()', + 'caller.py', + ); + emitPythonScopeCaptures( + 'class Worker:\n def target():\n pass\n @staticmethod\n def static_target():\n pass', + 'targets.py', + ); + + const site = { atRange: { startLine: 3, startCol: 15, endLine: 3, endCol: 28 } }; + const receiverless = { ...candidate(2), parameterCount: 0, requiredParameterCount: 0 }; + const staticTarget = { + ...candidate(5), + nodeId: 'def:targets.py#5:4:Method:static_target', + parameterCount: 0, + requiredParameterCount: 0, + }; + + expect( + pythonMissingReceiverSubtypeCandidateCompatibility('caller.py', site, receiverless), + ).toBe('unknown'); + expect( + pythonMissingReceiverSubtypeCandidateCompatibility('caller.py', site, staticTarget), + ).toBe('compatible'); + }); });