diff --git a/gitnexus/src/core/ingestion/languages/python/builtin-descriptors.ts b/gitnexus/src/core/ingestion/languages/python/builtin-descriptors.ts new file mode 100644 index 000000000..537c0608a --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/python/builtin-descriptors.ts @@ -0,0 +1,341 @@ +/** + * Decide whether a method decorator names one of Python's builtin descriptor + * types, following CPython's evaluation of the decorator expression. + * + * A decorator in a class body is evaluated with LOAD_NAME: the class + * namespace first, then module globals, then builtins, each as bound at the + * moment the `def` statement runs. Aliases, dotted names and decorator calls + * have no known descriptor contract without import resolution, so only bare + * spellings qualify. + */ + +import type { SyntaxNode } from '../../utils/ast-helpers.js'; + +const BUILTIN_DESCRIPTOR_NAMES = ['classmethod', 'staticmethod', 'property'] as const; + +export type BuiltinDescriptor = (typeof BUILTIN_DESCRIPTOR_NAMES)[number]; + +const BUILTIN_DESCRIPTORS: ReadonlySet = new Set(BUILTIN_DESCRIPTOR_NAMES); + +/** Decorator expressions, outermost first. Tree-sitter keeps a trailing + * comment inside the decorator node, so read the expression child only. */ +export function decoratorNames(fnNode: SyntaxNode): string[] { + const parent = fnNode.parent; + if (parent === null || parent.type !== 'decorated_definition') return []; + const names: string[] = []; + for (const child of parent.namedChildren) { + if (child.type !== 'decorator') continue; + // An empty name matches nothing, so a malformed decorator stays unknown. + names.push(child.namedChildren.find((part) => part.type !== 'comment')?.text ?? ''); + } + return names; +} + +/** Does `expression` denote a builtin descriptor type (or `kind`) at `fnNode`? */ +export function isBuiltinDescriptor( + fnNode: SyntaxNode, + expression: string, + kind?: BuiltinDescriptor, +): boolean { + if (kind === undefined ? !BUILTIN_DESCRIPTORS.has(expression) : expression !== kind) return false; + // Decorators are evaluated as the `def` statement runs, so its wrapper is the + // use site. No statement can rebind the name between stacked decorators. + const use = fnNode.parent?.type === 'decorated_definition' ? fnNode.parent : fnNode; + return lookupName(descriptorBindings(fnNode).get(expression) ?? [], use) !== 'shadow'; +} + +/** + * The effect a name-binding operation leaves (Language Reference 4.2.1): + * `builtin` re-imports the builtin object itself, `unbind` is a `del` that + * makes lookup fall through to the next namespace, and `shadow` binds any + * other value. + */ +type BindingEffect = 'shadow' | 'builtin' | 'unbind'; + +interface NameBinding { + readonly node: SyntaxNode; + readonly effect: BindingEffect; + /** Declared `global` / `nonlocal` in the binding function (4.2.2), so it + * writes an outer namespace whenever that function is called. */ + readonly redirect: 'global' | 'nonlocal' | null; +} + +const FUNCTION_SCOPES = new Set(['function_definition', 'lambda']); +const COMPREHENSIONS = new Set([ + 'list_comprehension', + 'set_comprehension', + 'dictionary_comprehension', + 'generator_expression', +]); +const TARGET_WRAPPERS = new Set([ + 'pattern_list', + 'tuple_pattern', + 'list_pattern', + 'list_splat_pattern', + 'dictionary_splat_pattern', + 'as_pattern_target', + 'expression_list', + 'parenthesized_expression', +]); +/** Simple statements whose effect always happens once execution reaches them. */ +const SIMPLE_STATEMENTS = new Set([ + 'expression_statement', + 'import_statement', + 'import_from_statement', + 'delete_statement', +]); + +/** Is `node` the `field` child of its parent? */ +function isField(node: SyntaxNode, field: string): boolean { + return node.parent?.childForFieldName(field)?.id === node.id; +} + +/** Does `statement` import from the `builtins` module? */ +function importsFromBuiltins(statement: SyntaxNode | null | undefined): boolean { + return statement?.childForFieldName('module_name')?.text === 'builtins'; +} + +/** + * The binding this identifier performs, following the binding constructs of + * Language Reference 4.2.1, or `null` for a plain read. + */ +function bindingOf(identifier: SyntaxNode): Omit | null { + let node = identifier; + let parent = node.parent; + while (parent !== null && TARGET_WRAPPERS.has(parent.type)) { + node = parent; + parent = node.parent; + } + if (parent === null) return null; + const shadow = { node: identifier, effect: 'shadow' as const }; + switch (parent.type) { + case 'assignment': + case 'augmented_assignment': + case 'for_statement': + case 'for_in_clause': + return isField(node, 'left') ? shadow : null; + case 'named_expression': + case 'function_definition': + case 'class_definition': + case 'default_parameter': + case 'typed_default_parameter': + return isField(node, 'name') ? shadow : null; + case 'as_pattern': + return isField(node, 'alias') ? shadow : null; + case 'aliased_import': { + if (!isField(node, 'alias')) return null; + // `from builtins import staticmethod as staticmethod` binds the builtin. + const source = parent.childForFieldName('name')?.text; + return importsFromBuiltins(parent.parent) && source === identifier.text + ? { node: identifier, effect: 'builtin' } + : shadow; + } + case 'typed_parameter': + return node.type === 'identifier' ? shadow : null; + case 'parameters': + case 'lambda_parameters': + return shadow; + case 'delete_statement': + return { node: identifier, effect: 'unbind' }; + case 'type': + return parent.parent?.type === 'type_parameter' || + (parent.parent?.type === 'type_alias_statement' && isField(parent, 'left')) + ? shadow + : null; + case 'dotted_name': { + const owner = parent.parent; + // `import a.b` binds `a`; `case name:` captures a single name. + if (owner?.type === 'import_statement') + return parent.firstNamedChild?.id === node.id ? shadow : null; + if (owner?.type === 'case_pattern') return parent.namedChildCount === 1 ? shadow : null; + if (owner?.type !== 'import_from_statement' || !isField(parent, 'name')) return null; + return importsFromBuiltins(owner) ? { node: identifier, effect: 'builtin' } : shadow; + } + default: + return null; + } +} + +const bindingsByTree = new WeakMap>(); + +/** Every binding of a builtin descriptor name in the file, in source order. */ +function descriptorBindings(node: SyntaxNode): ReadonlyMap { + const tree = node.tree; + const cached = bindingsByTree.get(tree); + if (cached !== undefined) return cached; + // `global x` / `nonlocal x` bind nothing themselves; they redirect the + // declaring scope's own bindings of `x` to an outer namespace. + const redirected = new Map(); + for (const statement of tree.rootNode.descendantsOfType([ + 'global_statement', + 'nonlocal_statement', + ])) { + const scope = scopeOf(statement)?.id; + const kind = statement.type === 'global_statement' ? 'global' : 'nonlocal'; + for (const name of statement.namedChildren) redirected.set(`${name.text}@${scope}`, kind); + } + const bindings = new Map(); + const add = (name: string, binding: NameBinding) => { + const list = bindings.get(name); + if (list === undefined) bindings.set(name, [binding]); + else list.push(binding); + }; + for (const found of tree.rootNode.descendantsOfType(['identifier', 'wildcard_import'])) { + if (found.type === 'wildcard_import') { + // `from m import *` binds every public name m defines. Unless m is + // `builtins`, whether that includes a descriptor name is unknown here. + const effect = importsFromBuiltins(found.parent) ? 'builtin' : 'shadow'; + for (const name of BUILTIN_DESCRIPTORS) add(name, { node: found, effect, redirect: null }); + continue; + } + if (!BUILTIN_DESCRIPTORS.has(found.text)) continue; + const binding = bindingOf(found); + if (binding === null) continue; + const redirect = redirected.get(`${found.text}@${scopeOf(found)?.id}`) ?? null; + add(found.text, { ...binding, redirect }); + } + bindingsByTree.set(tree, bindings); + return bindings; +} + +/** + * The scope that owns names bound at `node`: a function or lambda body, a + * class body, a comprehension, or the module (`null`). A walrus target skips + * comprehensions, as PEP 572 binds it in the enclosing scope. + */ +function scopeOf(node: SyntaxNode): SyntaxNode | null { + const skipComprehensions = node.parent?.type === 'named_expression'; + let child = node; + for (let parent = node.parent; parent !== null; child = parent, parent = parent.parent) { + if (FUNCTION_SCOPES.has(parent.type)) { + if (isField(child, 'body') || isField(child, 'parameters')) return parent; + } else if (parent.type === 'class_definition') { + if (isField(child, 'body')) return parent; + } else if (COMPREHENSIONS.has(parent.type) && !skipComprehensions) { + return parent; + } + } + return null; +} + +/** The statement containing `node` that sits directly in `body`. */ +function statementIn(node: SyntaxNode, body: SyntaxNode): SyntaxNode | null { + let current = node; + while (current.parent !== null && current.parent.id !== body.id) current = current.parent; + return current.parent === null ? null : current; +} + +/** Can `binding` run before `use` in the same scope, including an earlier + * iteration of an enclosing loop? */ +function mayRunBefore(binding: SyntaxNode, use: SyntaxNode, scope: SyntaxNode | null): boolean { + if (binding.startIndex < use.startIndex) return true; + for (let loop = use.parent; loop !== null && loop.id !== scope?.id; loop = loop.parent) { + if ( + (loop.type === 'for_statement' || loop.type === 'while_statement') && + binding.startIndex >= loop.startIndex && + binding.endIndex <= loop.endIndex + ) { + return true; + } + } + return false; +} + +/** + * What a module, class or function namespace holds for the name when + * execution reaches `use`. A `shadow` that may have run wins. A restoring + * effect (`builtin`, `unbind`) counts only when it is a simple statement + * directly in the scope body that runs before `use`, so it runs exactly once + * in order. + */ +function namespaceState( + bindings: readonly NameBinding[], + scope: SyntaxNode | null, + use: SyntaxNode, +): BindingEffect { + const body = scope === null ? null : scope.childForFieldName('body'); + let state: BindingEffect = 'unbind'; + for (const binding of bindings) { + if (binding.redirect !== null || scopeOf(binding.node)?.id !== scope?.id) continue; + if (!mayRunBefore(binding.node, use, scope)) continue; + if (binding.effect === 'shadow') { + state = 'shadow'; + continue; + } + const statement = statementIn(binding.node, body ?? binding.node.tree.rootNode); + const ordered = binding.node.startIndex < use.startIndex; + if (ordered && statement !== null && SIMPLE_STATEMENTS.has(statement.type)) { + state = binding.effect; + } + } + return state; +} + +/** + * Resolve the decorator name at `use` as a class body does (Language + * Reference 4.2.2): the class namespace first, then the innermost enclosing + * function that binds the name, then module globals, then builtins. + */ +function lookupName(bindings: readonly NameBinding[], use: SyntaxNode): BindingEffect { + // Enclosing scopes, innermost first, ending with the module (`null`). + const chain: (SyntaxNode | null)[] = []; + for (let scope = scopeOf(use); scope !== null; scope = scopeOf(scope)) chain.push(scope); + chain.push(null); + + const classScope = chain[0]?.type === 'class_definition' ? chain[0] : null; + if (classScope !== null) { + const state = namespaceState(bindings, classScope, use); + if (state !== 'unbind') return state; + } + + const functions = chain.filter( + (scope): scope is SyntaxNode => scope !== null && FUNCTION_SCOPES.has(scope.type), + ); + for (const fn of functions) { + const owned = bindings.some( + (binding) => binding.redirect === null && scopeOf(binding.node)?.id === fn.id, + ); + if (!owned) continue; + // Any binding makes the name local to this function, so the class body + // reads that cell. An unbound cell raises NameError, not the builtin. + return namespaceState(bindings, fn, use) === 'builtin' ? 'builtin' : 'shadow'; + } + + // A class body inside a function runs whenever that function is called, + // which can be any time after its top-level statement starts. Module state + // is therefore read at that statement, and any later module override may + // also have run first. + const deferred = functions.length > 0; + const moduleUse = deferred ? (statementIn(use, use.tree.rootNode) ?? use) : use; + for (const binding of bindings) { + if (binding.effect !== 'shadow') continue; + // A nested function can rebind an enclosing function's cell whenever it + // is called; that order is not modelled, so assume it ran. + if (binding.redirect === 'nonlocal') { + const outer = functions[functions.length - 1]; + const inside = + outer !== undefined && + binding.node.startIndex >= outer.startIndex && + binding.node.endIndex <= outer.endIndex; + if (inside) return 'shadow'; + } + if (binding.redirect === 'global') { + // The function can only be called once the top-level statement that + // defines it has run. Whether a call happens is unknown, so a restoring + // `global` delete is ignored and a rebinding one is assumed. + const top = statementIn(binding.node, binding.node.tree.rootNode); + if (deferred || (top !== null && mayRunBefore(top, use, null))) return 'shadow'; + } + } + if (deferred) { + const laterOverride = bindings.some( + (binding) => + binding.effect === 'shadow' && + binding.redirect === null && + scopeOf(binding.node) === null && + binding.node.startIndex > moduleUse.startIndex, + ); + if (laterOverride) return 'shadow'; + } + return namespaceState(bindings, null, moduleUse); +} diff --git a/gitnexus/src/core/ingestion/languages/python/receiver-binding.ts b/gitnexus/src/core/ingestion/languages/python/receiver-binding.ts index e1e93ae11..bf92049b4 100644 --- a/gitnexus/src/core/ingestion/languages/python/receiver-binding.ts +++ b/gitnexus/src/core/ingestion/languages/python/receiver-binding.ts @@ -12,6 +12,7 @@ import type { CaptureMatch } from 'gitnexus-shared'; import { nodeToCapture, syntheticCapture, type SyntaxNode } from '../../utils/ast-helpers.js'; +import { decoratorNames, isBuiltinDescriptor } from './builtin-descriptors.js'; /** Walk up to the enclosing `class_definition`, ignoring the immediate * `decorated_definition` wrapper. Returns `null` when the function is @@ -30,32 +31,6 @@ function classDefinitionName(classNode: SyntaxNode): string | null { return classNode.childForFieldName('name')?.text ?? null; } -/** Syntactic decorator expressions; aliases cannot be identified by spelling. */ -function decoratorNames(fnNode: SyntaxNode): string[] { - const parent = fnNode.parent; - if (parent === null || parent.type !== 'decorated_definition') return []; - const names: string[] = []; - for (let i = 0; i < parent.namedChildCount; i++) { - const child = parent.namedChild(i); - if (child === null || child.type !== 'decorator') continue; - const text = child.text.replace(/^@/, '').trim(); - names.push(text); - } - return names; -} - -/** Matches bare and module-qualified decorator spellings. */ -function hasDecorator(fnNode: SyntaxNode, decoratorName: string): boolean { - return decoratorNames(fnNode).some((expression) => { - const name = expression.split('(')[0]!.trim(); - return name === decoratorName || name.endsWith(`.${decoratorName}`); - }); -} - -// These spellings have a known descriptor contract in ordinary Python code. -// Arbitrary dotted tails, aliases and decorator calls do not. -const KNOWN_RECEIVER_DECORATORS = new Set(['classmethod', 'staticmethod', 'property']); - /** Accept a local `@property` accessor chain, skipping only plain unrelated * methods. Other intervening class-suite statements may rebind the descriptor, * including tuple assignment or control flow. */ @@ -95,12 +70,10 @@ function isLocalPropertyAccessor(fnNode: SyntaxNode, expression: string): boolea if (child?.type === 'function_definition') candidate = child; } if (candidate?.childForFieldName('name')?.text !== methodName) return false; - const decorators = decoratorNames(candidate); - if (decorators.length !== 1) return false; - if (decorators[0] === 'property') return true; - if ( - !['getter', 'setter', 'deleter'].some((kind) => decorators[0] === `${methodName}.${kind}`) - ) { + const [decorator, ...rest] = decoratorNames(candidate); + if (decorator === undefined || rest.length > 0) return false; + if (isBuiltinDescriptor(candidate, decorator, 'property')) return true; + if (!['getter', 'setter', 'deleter'].some((kind) => decorator === `${methodName}.${kind}`)) { return false; } } @@ -110,12 +83,29 @@ function isLocalPropertyAccessor(fnNode: SyntaxNode, expression: string): boolea /** 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') + fnNode.childForFieldName('name')?.text === '__new__' || + decoratorNames(fnNode).some((name) => isBuiltinDescriptor(fnNode, name, 'staticmethod')) + ); +} + +/** + * Can the method's parameter list prove its call shape? Only a plain function + * or one builtin `staticmethod` / `classmethod` wrapper qualifies. Any other + * decorator may replace the callable, and a descriptor stack can make it + * uncallable: `staticmethod(classmethod(f))` yields a classmethod object. + */ +export function hasPythonProvenCallShape(fnNode: SyntaxNode): boolean { + const [decorator, ...rest] = decoratorNames(fnNode); + if (decorator === undefined) return true; + return ( + rest.length === 0 && + (isBuiltinDescriptor(fnNode, decorator, 'staticmethod') || + isBuiltinDescriptor(fnNode, decorator, 'classmethod')) ); } function isKnownReceiverDecorator(fnNode: SyntaxNode, expression: string): boolean { - return KNOWN_RECEIVER_DECORATORS.has(expression) || isLocalPropertyAccessor(fnNode, expression); + return isBuiltinDescriptor(fnNode, expression) || isLocalPropertyAccessor(fnNode, expression); } function firstBoundReceiverParameter(parameters: SyntaxNode): SyntaxNode | null { @@ -179,9 +169,6 @@ export function classifyPythonBoundReceiver(fnNode: SyntaxNode): PythonBoundRece } 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. const params = fnNode.childForFieldName('parameters'); if (params === null) return null; const parameter = firstBoundReceiverParameter(params); @@ -192,6 +179,8 @@ export function classifyPythonBoundReceiver(fnNode: SyntaxNode): PythonBoundRece if (name === null || className === null) return null; return { + // Python makes __init_subclass__ and __class_getitem__ implicit + // classmethods, so they receive the class even without a decorator. kind: decorators.includes('classmethod') || functionName === '__init_subclass__' || @@ -239,7 +228,10 @@ export function classifyPythonUncertainReceiver(fnNode: SyntaxNode): PythonBound // Python applies decorators bottom-up. An outer built-in staticmethod // guarantees no implicit receiver even when an inner decorator is opaque. // The first parameter remains explicit and can keep its annotation. - if (decorators[0] === 'staticmethod') return null; + const outermost = decorators[0]; + if (outermost !== undefined && isBuiltinDescriptor(fnNode, outermost, 'staticmethod')) { + return null; + } if (decorators.every((name) => isKnownReceiverDecorator(fnNode, name))) return null; const enclosingClass = findEnclosingClassDefinition(fnNode); const parameters = fnNode.childForFieldName('parameters'); @@ -370,7 +362,6 @@ function constructorCallTypeName( export function synthesizeConstructorFieldTypeBindings(fnNode: SyntaxNode): CaptureMatch[] { if (fnNode.childForFieldName('name')?.text !== '__init__') return []; if (findEnclosingClassDefinition(fnNode) === null) return []; - if (hasDecorator(fnNode, 'staticmethod') || hasDecorator(fnNode, 'classmethod')) return []; const receiver = synthesizeReceiverTypeBinding(fnNode); const receiverName = receiver?.['@type-binding.self']?.text; diff --git a/gitnexus/src/core/ingestion/languages/python/subtype-dispatch.ts b/gitnexus/src/core/ingestion/languages/python/subtype-dispatch.ts index 371031320..5f02909e1 100644 --- a/gitnexus/src/core/ingestion/languages/python/subtype-dispatch.ts +++ b/gitnexus/src/core/ingestion/languages/python/subtype-dispatch.ts @@ -1,7 +1,11 @@ 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, isPythonStaticLikeMethod } from './receiver-binding.js'; +import { + classifyPythonBoundReceiver, + hasPythonProvenCallShape, + isPythonStaticLikeMethod, +} from './receiver-binding.js'; type PositionTuple = readonly [line: number, column: number]; type CallShapeTuple = readonly [line: number, column: number, positionalCount: number]; @@ -124,20 +128,9 @@ 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; - } + // An unknown decorator may replace the function or mark it abstract, so + // its parameter list cannot prove a concrete subtype dispatch target. + if (!hasPythonProvenCallShape(fnNode)) return; const capacity = positionalCapacity(fnNode); if (capacity === undefined) return; const [line, column] = nodePosition(fnNode, mapLine); 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 f4fec6a0c..4c556e967 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 @@ -2479,9 +2479,7 @@ export function emitReceiverBoundCalls( 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); - } + missingMemberSubtypeIds.add(subtype.nodeId); continue; } subtypeTargets.set(picked.nodeId, picked); diff --git a/gitnexus/src/storage/parse-cache.ts b/gitnexus/src/storage/parse-cache.ts index 908aadb36..9e981b4e4 100644 --- a/gitnexus/src/storage/parse-cache.ts +++ b/gitnexus/src/storage/parse-cache.ts @@ -808,7 +808,11 @@ import { copyV8CacheIfPresent, tryLoadV8Cache, writeV8CacheFile } from './v8-sid // v120 (#3394): decorated Python method receiver bindings now distinguish // unproven decorators from instance receivers. Warm v119 ParsedFiles would // replay a fabricated `self` binding or lack the uncertainty marker entirely. -const SCHEMA_BUMP = 120; +// v121 (#3399 follow-up): Python decorator identity now ignores trailing +// comments, honors rebinding of builtin descriptor names visible where the +// decorator is evaluated, and withholds subtype capacity from descriptor +// stacks. Warm v120 captures carry the old verdicts. +const SCHEMA_BUMP = 121; 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 a510571a2..646fef3b0 100644 --- a/gitnexus/test/integration/resolvers/python.test.ts +++ b/gitnexus/test/integration/resolvers/python.test.ts @@ -1349,9 +1349,9 @@ 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-')); +describe('Python unproven subtype methods', () => { + it('keeps unproven subtype targets unresolved beside a concrete sibling', async () => { + const repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-python-unproven-subtype-')); try { writeFixtureRepo(repoDir, { 'worker.py': [ @@ -1359,62 +1359,69 @@ describe('Python aliased abstract subtype method', () => { 'class Mixin:', ' def dispatch(self):', ' return self.hook()', + 'class Concrete(Mixin):', + ' def hook(self):', + ' return 0', 'class AbstractWorker(Mixin, ABC):', ' @am', ' def hook(self):', ' return 1', + 'class Receiverless(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); + const calls = getRelationships(result, 'CALLS').filter( + (call) => call.source === 'dispatch' && call.target === 'hook', + ); + expect(calls.map((call) => call.rel.targetId)).toEqual([ + expect.stringContaining('Concrete.hook'), + ]); + const unresolved = getResolutionOutcomes(result).filter( + (outcome) => + outcome.kind === 'suppressed' && + outcome.name === 'hook' && + outcome.reason === 'receiver-unresolved', + ); + expect(unresolved.flatMap((outcome) => outcome.candidateIds).sort()).toEqual([ + // AbstractWorker.hook (line 10) and Receiverless.hook (line 13). + 'def:worker.py#10:4:Method:hook', + 'def:worker.py#13:4:Method:hook', + ]); } finally { fs.rmSync(repoDir, { recursive: true, force: true }); } }, 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-')); + it('marks a member-less intermediate subtype partial and keeps the leaf edge', async () => { + const repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-python-intermediate-subtype-')); try { writeFixtureRepo(repoDir, { 'worker.py': [ 'class Mixin:', ' def dispatch(self):', ' return self.hook()', - 'class Worker(Mixin):', - ' def hook():', - ' pass', + // Base() is instantiable, and its dispatch() raises AttributeError. + 'class Base(Mixin):', + ' pass', + 'class Impl(Base):', + ' def hook(self):', + ' return 1', ].join('\n'), }); const result = await runPipelineFromRepo(repoDir, () => {}); + const calls = getRelationships(result, 'CALLS').filter( + (call) => call.source === 'dispatch' && call.target === 'hook', + ); + expect(calls.map((call) => call.rel.targetId)).toEqual([ + expect.stringContaining('Impl.hook'), + ]); 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); + getResolutionOutcomes(result) + .filter((outcome) => outcome.name === 'hook' && outcome.reason === 'receiver-unresolved') + .flatMap((outcome) => outcome.candidateIds), + ).toEqual([expect.stringMatching(/:Class:Base$/)]); } finally { fs.rmSync(repoDir, { recursive: true, force: true }); } diff --git a/gitnexus/test/unit/incremental-parse-cache.test.ts b/gitnexus/test/unit/incremental-parse-cache.test.ts index d514a8a27..2376e5857 100644 --- a/gitnexus/test/unit/incremental-parse-cache.test.ts +++ b/gitnexus/test/unit/incremental-parse-cache.test.ts @@ -298,8 +298,9 @@ describe('PARSE_CACHE_VERSION', () => { // Moved 115 -> 116 for #3390's Python subtype-dispatch shape side-channel. // Moved 116 -> 117 for #3390's private positional-count side-channel. // Moved 117 -> 118 for #3398, 118 -> 119 for #3396, and 119 -> 120 for #3394. - it('pins SCHEMA_BUMP to 120 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, #3394)', () => { - expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(120); + // Moved 120 -> 121 for the #3399 decorator-identity follow-up. + it('pins SCHEMA_BUMP to 121 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, #3394, #3399)', () => { + expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(121); 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 @@ -308,7 +309,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, 118, 119, + 104, 105, 106, 107, 108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, ]) { expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).not.toBe(taken); } diff --git a/gitnexus/test/unit/method-extraction.test.ts b/gitnexus/test/unit/method-extraction.test.ts index 9bdfe2f12..f792cfdce 100644 --- a/gitnexus/test/unit/method-extraction.test.ts +++ b/gitnexus/test/unit/method-extraction.test.ts @@ -2792,6 +2792,32 @@ class Service: }); }); + it('reads decorator identity from the expression, not its trailing comment', () => { + const tree = parsePython(` +class Service: + @staticmethod # type: ignore[misc] + def commented(value): + pass + `); + const result = extractor.extract(tree.rootNode.child(0)!, pythonCtx); + + expect(result!.methods[0]!.parameters.map((parameter) => parameter.name)).toEqual(['value']); + }); + + it('keeps a static first parameter when the file only reads staticmethod', () => { + const tree = parsePython(` +class Service: + @staticmethod + def build(value): + pass + +helper = staticmethod(len) + `); + const result = extractor.extract(tree.rootNode.child(0)!, pythonCtx); + + expect(result!.methods[0]!.parameters.map((parameter) => parameter.name)).toEqual(['value']); + }); + it('retains first parameters on module and nested functions', () => { const tree = parsePython(` def module(instance): diff --git a/gitnexus/test/unit/scope-resolution/python/python-builtin-descriptors.test.ts b/gitnexus/test/unit/scope-resolution/python/python-builtin-descriptors.test.ts new file mode 100644 index 000000000..823042b8d --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/python/python-builtin-descriptors.test.ts @@ -0,0 +1,247 @@ +import { describe, expect, it } from 'vitest'; +import Parser from 'tree-sitter'; +import Python from 'tree-sitter-python'; +import { isBuiltinDescriptor } from '../../../../src/core/ingestion/languages/python/builtin-descriptors.js'; + +const parser = new Parser(); +parser.setLanguage(Python); + +/** Is `@staticmethod` on the function named `t` the builtin descriptor? */ +const decoratesWithBuiltin = (source: string): boolean => { + const target = parser + .parse(source) + .rootNode.descendantsOfType('function_definition') + .find((fn) => fn.childForFieldName('name')?.text === 't')!; + return isBuiltinDescriptor(target, 'staticmethod', 'staticmethod'); +}; + +const method = [' @staticmethod', ' def t(v):', ' return v']; + +// `true` means CPython's `A().t(7)` returns 7, so the decorator evaluated to +// the builtin staticmethod. A wildcard import from a module this file cannot +// see is expected `false` because the resolver fails closed, not because +// CPython always shadows the builtin there. +describe('Python builtin descriptor identity', () => { + it.each([ + ['a later module assignment', ['class A:', ...method, 'staticmethod = lambda f: f'], true], + ['a plain read', ['g = staticmethod(len)', 'class A:', ...method], true], + [ + 'an import of the builtin itself', + ['from builtins import staticmethod', 'class A:', ...method], + true, + ], + ['a later class-body assignment', ['class A:', ...method, ' staticmethod = 1'], true], + [ + 'an outer class-body assignment', + ['class O:', ' staticmethod = 1', ' class A:', ...method.map((line) => ` ${line}`)], + true, + ], + [ + 'a sibling function local', + ['def other():', ' staticmethod = 1', 'class A:', ...method], + true, + ], + ['an earlier module assignment', ['staticmethod = lambda f: f', 'class A:', ...method], false], + [ + 'an earlier import alias', + ['from abc import abstractmethod as staticmethod', 'class A:', ...method], + false, + ], + ['an earlier wildcard import', ['from helpers import *', 'class A:', ...method], false], + ['an earlier class-body assignment', ['class A:', ' staticmethod = 1', ...method], false], + [ + 'a later assignment in the same loop', + [ + 'for i in range(2):', + ' class A:', + ...method.map((line) => ` ${line}`), + ' staticmethod = lambda f: f', + ], + false, + ], + [ + 'a module assignment after a deferred class body', + [ + 'def make():', + ' class A:', + ...method.map((line) => ` ${line}`), + ' return A', + 'staticmethod = lambda f: f', + ], + false, + ], + [ + 'a later local in the enclosing function', + [ + 'def make():', + ' class A:', + ...method.map((line) => ` ${line}`), + ' staticmethod = 1', + ], + false, + ], + [ + 'a global declaration alone', + ['class A:', ...method, 'def rebind():', ' global staticmethod'], + true, + ], + ['an unconditional del', ['staticmethod = 1', 'del staticmethod', 'class A:', ...method], true], + [ + 'a class-body del', + ['class A:', ' staticmethod = 1', ' del staticmethod', ...method], + true, + ], + [ + 'a conditional del', + ['staticmethod = 1', 'if False:', ' del staticmethod', 'class A:', ...method], + false, + ], + [ + 'a same-name builtins re-export', + ['from builtins import staticmethod as staticmethod', 'class A:', ...method], + true, + ], + [ + 'a global rebind defined after the class', + [ + 'class A:', + ...method, + 'def rebind():', + ' global staticmethod', + ' staticmethod = lambda f: f', + 'rebind()', + ], + true, + ], + [ + 'a global rebind defined before the class', + [ + 'def rebind():', + ' global staticmethod', + ' staticmethod = 1', + 'rebind()', + 'class A:', + ...method, + ], + false, + ], + [ + 'a builtins import after an override', + ['staticmethod = lambda f: f', 'from builtins import staticmethod', 'class A:', ...method], + true, + ], + ['a builtins wildcard import', ['from builtins import *', 'class A:', ...method], true], + [ + 'a class-body builtins import over a module override', + ['staticmethod = 1', 'class A:', ' from builtins import staticmethod', ...method], + true, + ], + [ + 'a conditional builtins import after an override', + [ + 'staticmethod = 1', + 'if flag:', + ' from builtins import staticmethod', + 'class A:', + ...method, + ], + false, + ], + [ + // CPython restores the builtin when reset() runs; whether a call runs is + // not modelled, so the resolver keeps the override (fail closed). + 'a global del in a called helper', + [ + 'staticmethod = lambda f: f', + 'def reset():', + ' global staticmethod', + ' del staticmethod', + 'reset()', + 'class A:', + ...method, + ], + false, + ], + [ + // Each class statement builds a fresh namespace, so the decorator runs + // before that iteration's own class-body assignment. + 'a later class-body assignment inside a loop', + [ + 'for i in range(2):', + ' class A:', + ...method.map((line) => ` ${line}`), + ' staticmethod = 1', + ], + true, + ], + [ + 'a builtins import in the enclosing function', + [ + 'def make():', + ' from builtins import staticmethod', + ' class A:', + ...method.map((line) => ` ${line}`), + ' staticmethod = 1', + ], + true, + ], + [ + 'an enclosing-function local assigned before the class', + [ + 'def make():', + ' staticmethod = 1', + ' class A:', + ...method.map((line) => ` ${line}`), + ], + false, + ], + [ + 'an enclosing-function local assigned only after the class', + [ + 'def make():', + ' class A:', + ...method.map((line) => ` ${line}`), + ' staticmethod = 1', + ], + false, + ], + [ + 'a nonlocal rebind in a nested function', + [ + 'def make():', + ' staticmethod = 1', + ' def rebind():', + ' nonlocal staticmethod', + ' staticmethod = 2', + ' class A:', + ...method.map((line) => ` ${line}`), + ], + false, + ], + [ + 'a module del before a deferred class body', + [ + 'staticmethod = lambda f: f', + 'del staticmethod', + 'def make():', + ' class A:', + ...method.map((line) => ` ${line}`), + ], + true, + ], + [ + // make() may run while the override is still bound. + 'a module del after a deferred class body', + [ + 'staticmethod = lambda f: f', + 'def make():', + ' class A:', + ...method.map((line) => ` ${line}`), + 'del staticmethod', + ], + false, + ], + ])('with %s', (_case, lines, builtin) => { + expect(decoratesWithBuiltin(lines.join('\n'))).toBe(builtin); + }); +}); 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 fc90521a5..805d9b99e 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 @@ -201,4 +201,86 @@ describe('Python missing-member subtype argument shapes', () => { pythonMissingReceiverSubtypeCandidateCompatibility('caller.py', site, staticTarget), ).toBe('compatible'); }); + + it('resolves decorator identity the way CPython evaluates it', () => { + emitPythonScopeCaptures(callerSource, 'caller.py'); + emitPythonScopeCaptures( + [ + 'from abc import abstractmethod', + 'class Commented:', + ' @staticmethod # type: ignore[misc]', + ' def target(value):', + ' return value', + 'class Stacked:', + ' @staticmethod', + ' @classmethod', + ' def target(cls, value):', + ' return value', + 'class Receiverless:', + ' def target(**options):', + ' return options', + 'class KeywordOnlyReceiverless:', + ' def target(*, value=0):', + ' return value', + ].join('\n'), + 'targets.py', + ); + emitPythonScopeCaptures( + [ + // `import X as Y` binds only Y, so the builtin stays visible. + 'from builtins import staticmethod as sm', + 'class Aliased:', + ' @staticmethod', + ' def target(value):', + ' return value', + ].join('\n'), + 'aliased.py', + ); + emitPythonScopeCaptures( + [ + 'from abc import abstractmethod as staticmethod', + 'class Shadowed:', + ' @staticmethod', + ' def target(value):', + ' return value', + ].join('\n'), + 'shadowed.py', + ); + const receiverless = (line: number) => ({ + ...candidate(line), + parameterCount: 0, + requiredParameterCount: 0, + }); + const shadowed = { ...candidate(4), nodeId: 'def:shadowed.py#4:4:Method:target' }; + const verdict = ( + site: Parameters[1], + target: SymbolDefinition, + ) => pythonMissingReceiverSubtypeCandidateCompatibility('caller.py', site, target); + + expect({ + // A trailing comment is not part of the decorator expression. + commentedStaticOneArg: verdict(positionalSite, candidate(4)), + commentedStaticNoArg: verdict(tooFewSite, candidate(4)), + // staticmethod(classmethod(f)) yields a non-callable classmethod object. + stackedDescriptors: verdict(positionalSite, candidate(9)), + // Python still passes the instance, which these signatures cannot bind. + receiverlessKwargs: verdict(tooFewSite, receiverless(12)), + receiverlessKeywordOnly: verdict(tooFewSite, receiverless(15)), + // The module rebinds `staticmethod`, so the decorator is not the builtin. + shadowedStatic: verdict(positionalSite, { ...shadowed, filePath: 'shadowed.py' }), + aliasedBuiltinImport: verdict(positionalSite, { + ...candidate(4), + nodeId: 'def:aliased.py#4:4:Method:target', + filePath: 'aliased.py', + }), + }).toEqual({ + commentedStaticOneArg: 'compatible', + commentedStaticNoArg: 'incompatible', + stackedDescriptors: 'unknown', + receiverlessKwargs: 'unknown', + receiverlessKeywordOnly: 'unknown', + shadowedStatic: 'unknown', + aliasedBuiltinImport: 'compatible', + }); + }); });