mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-06 02:49:56 +00:00
fix(python): resolve decorator identity like CPython (#3411)
* fix(python): resolve decorator identity like CPython Decorator identity was decided by two different predicates. One read the raw decorator text, so a trailing comment such as `@staticmethod # type: ignore` hid the builtin and turned an explicit first parameter into a fabricated receiver. The other trusted any `staticmethod` spelling, including a module-level rebinding and a `staticmethod(classmethod(f))` stack, which CPython cannot call. Read the decorator expression node only, and recognize a bare builtin descriptor only when the file does not rebind that name. Publish subtype capacity only for a plain function or a single builtin staticmethod or classmethod wrapper. Drop a no-op coverage guard, move the implicit classmethod comment next to the code it describes, and bump the parse cache to v121. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix(python): shadow builtin descriptors only by visible bindings The whole-file identifier scan counted plain reads (`staticmethod(f)`), `from builtins import staticmethod`, and bindings that run after the decorator as rebindings. CPython evaluates a class-body decorator with LOAD_NAME when the `def` runs, so none of those change which object the decorator names. Methods decorated with the real builtin lost their subtype call shape, and static methods lost their first parameter in arity metadata. Move decorator identity into builtin-descriptors.ts and count only binding occurrences (assignment and loop targets, walrus, def/class, parameters, import aliases, except/with/match captures, del, type parameters, and wildcard imports) that are visible where the decorator runs: the class body or module before the definition, a repeating enclosing loop, any binding in an enclosing function, and any global/nonlocal rebind. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix(python): model global and del like CPython's symbol table `global x` and `nonlocal x` bind nothing; they redirect the declaring function's own bindings of `x` to an outer scope. A bare declaration was treated as an unconditional rebinding, so `@staticmethod` anywhere in the file lost builtin recognition. A module- or class-level `del` restores the outer lookup rather than binding the name. Treat an unconditional `del` that runs after a binding and before the decorator as undoing that binding. A `del` inside control flow may not run, and a `del` inside a function still makes the name local there. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix(python): order global rebinds and honor builtins re-exports A function that declares `global staticmethod` and assigns it rebinds the module name only when called, and it cannot be called before the top-level statement that defines it runs. Treat such a rebind as visible only when that statement precedes the decorator, or when the decorator sits in a deferred class body. `nonlocal` rebinds stay visible anywhere in the enclosing function. `from builtins import staticmethod as staticmethod` binds the builtin to its own name, so it no longer counts as shadowing. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * test(python): scope the descriptor-identity CPython claim The wildcard-import case expects the fail-closed resolver verdict, not a CPython outcome, because the imported module's exports are unknown. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix(python): resolve descriptor names as LOAD_NAME does Model each binding by its effect on the namespace (Language Reference 4.2.1): an import that binds the `builtins` object itself (plain, aliased to the same name, or `from builtins import *`) restores the builtin, a module- or class-level `del` unbinds so lookup falls through, and any other binding shadows. Resolve the decorator like LOAD_NAME (4.2.2): the class namespace, then module globals, then builtins, each as it stands when the `def` runs. A restoring effect counts only as an unconditional simple statement that runs before the decorator, so an import or `del` under `if`/`try` or a loop stays fail-closed. A helper's `global` delete depends on whether the helper is called, which the resolver does not model, so it keeps the override. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * refactor(python): simplify decorator descriptor lookup Derive the descriptor type and name set from one `as const` list, replace indexed non-null assertions with destructuring, build the scope chain without an assertion, and skip the enclosing-function owner lookup when the decorator has no enclosing function. Key the stacked-decorator verdicts by case name so a failure names its case. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix(python): resolve enclosing-function and deferred descriptor lookups A class body reads a free name from the innermost enclosing function that binds it (Language Reference 4.2.2). Evaluate that function's namespace the same way as the class and module ones, so an unconditional `from builtins import staticmethod` there resolves to the builtin. Any other binding still shadows, including a local assigned only after the class, which raises NameError rather than falling back to the builtin. A class body inside a function runs whenever that function is called, which can be any time after its top-level statement starts. Read module state at that statement instead of after the whole module, so an earlier `del` restores the builtin, and treat any later module override as possibly visible. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com> Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
0bcddec8e6
commit
821ffb2fcb
10 changed files with 788 additions and 98 deletions
|
|
@ -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<string> = 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<NameBinding, 'redirect'> | 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<object, ReadonlyMap<string, readonly NameBinding[]>>();
|
||||
|
||||
/** Every binding of a builtin descriptor name in the file, in source order. */
|
||||
function descriptorBindings(node: SyntaxNode): ReadonlyMap<string, readonly NameBinding[]> {
|
||||
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<string, 'global' | 'nonlocal'>();
|
||||
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<string, NameBinding[]>();
|
||||
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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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<typeof pythonMissingReceiverSubtypeCandidateCompatibility>[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',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue